// Sotce Net, 24.06.13.06.38 // A paid diary network by Sotce & Aesthetic Computer. /* #region π’ TODO + Now - [] Add custom meta descriptions for specific paths like '/chat'. - [] Add sound (to chat) and other buttons. - [] Pictures in pages. - [] Custom pictures or preset clip art from @amelia? - [] Pictures in line with text or like an overlay scrapbook style? - [] Add notifications to chat. + Later - [] Add "snippet" endpoint to get @amelia's latest page so it can be rendered on the login screen. *** π§ Email Notifications for Pages *** - [] email new pages to each subscriber, and include the contents? - [] make an 'eblast' endpoint for this - [] add the checkbox under the main page for whether to receive them or not *** 'Think' *** - [] Add meditation timer via AC sound engine, and sounds to buttons. *** π Sounds *** - [] Soft sine clicks and beeps. *** π Page Feed *** - [] Add multi-user page feed. *** π Page Controls *** - [] Automatic Dark Theme - [c] Patreon linkage. *** Accessibility *** - [] Accurate Tab Index in Modes/ different screens. - [] Login - [] Cookie Menu - [] Editor - [] Cleaner Ctrl +/- zoom logic / layout fixes. - [] Relational scrolling. - [] Better routing / slash urls for the editor and cookie-menu. - [] Shouldn't resolve if no access. *** User Info Rate Limiting *** - [] Try to reduce the authorize() call rate limiting on ac. + Done - [x] Test on mobile. - [x] Deploy production chat. - [x] Delete any chat messages owned by a user when their account gets deleted. - [x] Test guest chat interface with logged in (unsubscribed) user. - [x] Display chatter count in the chat interface. - [x] Add an 'editor' route and an unsaved changes confirmation? - [x] Add proper url route support for `chat` on both the client title and server, in splash screen and in main ui. - [x] Finish bottom bar design. - [x] Show character limit / enforce on the text input. - [x] Limit scrollback to 100 chats. - [x] Handle chat disconnection / reconnection UI. - [x] Fix gate reload. / Add 'gate' route. - [x] Send a new message to chat and auto-scroll. - [x] Shouldn't be able to send blank messages. - [x] `chat` needs a special URL route that automatically opens to it. - [x] Test chat interface with subscribed user. - [x] Hide chat until pink spinner dot disappears. - [c] Add exporting of PNG images per pages. - [x] Test and enable printing on iOS. #endregion */ // β»οΈ Environment const AUTH0_CLIENT_ID_SPA = "3SvAbUDFLIFZCc1lV7e4fAAGKWXwl2B0"; const AUTH0_DOMAIN = "https://hi.sotce.net"; const dev = process.env.NETLIFY_DEV; // π³ Payment import { SOTCE_STRIPE_API_PRIV_KEY, SOTCE_STRIPE_API_PUB_KEY, SOTCE_STRIPE_API_TEST_PRIV_KEY, SOTCE_STRIPE_API_TEST_PUB_KEY, SOTCE_STRIPE_ENDPOINT_DEV_SECRET, SOTCE_STRIPE_ENDPOINT_SECRET, priceId, productId, prodProductId, } from "../../backend/sotce-net-constants.mjs"; import { defaultTemplateStringProcessor as html } from "../../public/aesthetic.computer/lib/helpers.mjs"; import { respond } from "../../backend/http.mjs"; import { connect, ObjectId } from "../../backend/database.mjs"; import { shell } from "../../backend/shell.mjs"; import { authorize, deleteUser, hasAdmin, handleFor, getHandleOrEmail, userIDFromEmail, } from "../../backend/authorization.mjs"; import * as KeyValue from "../../backend/kv.mjs"; import Stripe from "stripe"; const dateOptions = { weekday: "long", year: "numeric", month: "long", day: "numeric", }; export const handler = async (event, context) => { // console.log("Event:", event, "Context:", context, "Path:", event.path); // π Router const method = event.httpMethod.toLowerCase(); let path = event.path; if (path.startsWith("/api/sotce-net")) path = path.replace("/api/sotce-net", "/").replace("//", "/"); if (path.startsWith("/sotce-net")) path = path.replace("/sotce-net", "/").replace("//", "/"); if (path.startsWith("/sotce.net")) path = path.replace("/sotce.net", "/").replace("//", "/"); const key = dev ? SOTCE_STRIPE_API_TEST_PRIV_KEY : SOTCE_STRIPE_API_PRIV_KEY; const assetPath = dev ? "/assets/sotce-net/" : "https://assets.aesthetic.computer/sotce-net/"; const baseHost = event.headers["host"] || "localhost"; const HOST = dev ? `https://${baseHost}/sotce-net` : `https://${baseHost}`; // π Admin emails that get subscriber access without Stripe const ADMIN_EMAILS = ["me@jas.life", "sotce.net@gmail.com"]; // Check to see if a user sub is subscribed. async function subscribed(user) { // π Admin bypass - grant subscriber access without checking Stripe if (user.email && ADMIN_EMAILS.includes(user.email.toLowerCase())) { shell.log("π Admin bypass granted for:", user.email); return { status: "active", current_period_end: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60, // 1 year from now admin_bypass: true, }; } // TODO: π Add redis caching in here. try { // π©· First look to see if we have a subscribed entry in the redis cache. shell.log( "Checking subscription for:", user.sub, user.email, performance.now(), ); await KeyValue.connect(); const cachedSubscription = await KeyValue.get( "sotce-subscribed", user.sub, ); if (cachedSubscription) { const parsed = JSON.parse(cachedSubscription); // Check to see if the time on parses has passed, and if it has // then invalidate the cache and continue to checking it with Stripe. const currentTime = Math.floor(Date.now() / 1000); const subscriptionEndTime = parsed.current_period_end; // Assume unix timestamp. if (subscriptionEndTime > currentTime) { shell.log("π° Subscription active from cache!", performance.now()); await KeyValue.disconnect(); return parsed; } else { shell.log( "β Subscription expired, invalidating cache and checking...", ); await KeyValue.del("sotce-subscribed", user.sub); } } shell.log("π Checking Stripe for subscription..."); // π©· Then query it from Stripe, const stripe = Stripe(key); // Fetch customer by user ID (sub) from subscription metadata field. const customers = await stripe.customers.search({ query: "metadata['sub']:'" + user.sub + "'", }); if (!customers.data.length) { shell.log("β No customer found in Stripe"); await KeyValue.disconnect(); return { subscribed: false }; } const customer = customers.data[0]; shell.log("β Customer found:", customer.id); // Fetch subscriptions for the customer const subscriptions = await stripe.subscriptions.list({ customer: customer.id, status: "active", // Only find the first active subscription. limit: 5, }); shell.log("π Found subscriptions:", subscriptions.data.length); const subscription = subscriptions.data.find((sub) => sub.items.data.some((item) => item.price.product === productId), ); if (subscription) { shell.log("β Active subscription found:", subscription.status); // π©· And serialize it into redis. await KeyValue.set( "sotce-subscribed", user.sub, JSON.stringify({ status: subscription.status, current_period_end: subscription.current_period_end, }), ); await KeyValue.disconnect(); return subscription; } else { shell.log("β No active subscription found"); await KeyValue.disconnect(); return { subscribed: false }; } } catch (err) { shell.error("Error fetching subscription status:", err); return null; } } // Get the count of all active subscriptions for the given productId // TODO: Put this behind a redis cache... 24.10.14.01.23 async function getActiveSubscriptionCount(productId) { try { const stripe = Stripe(key); // Fetch all subscriptions with the active status let hasMore = true; let totalSubscriptions = 0; let startingAfter = undefined; while (hasMore) { const subscriptions = await stripe.subscriptions.list({ status: "active", limit: 100, // Maximum allowed per request starting_after: startingAfter, }); // Filter subscriptions by the productId const matchingSubscriptions = subscriptions.data.filter((sub) => sub.items.data.some((item) => item.price.product === productId), ); totalSubscriptions += matchingSubscriptions.length; // Check if more pages of subscriptions exist hasMore = subscriptions.has_more; if (hasMore) { startingAfter = subscriptions.data[subscriptions.data.length - 1].id; } } return totalSubscriptions; } catch (err) { shell.error("Error fetching subscription count:", err); } } // Get the cumulative count of all subscriptions ever created for the given // productId (any status β active, canceled, etc.). // TODO: Put this behind a redis cache... 24.10.14.01.23 async function getCumulativeSubscriptionCount(productId) { try { const stripe = Stripe(key); // Fetch all subscriptions regardless of status. let hasMore = true; let totalSubscriptions = 0; let startingAfter = undefined; while (hasMore) { const subscriptions = await stripe.subscriptions.list({ status: "all", limit: 100, // Maximum allowed per request starting_after: startingAfter, }); // Filter subscriptions by the productId const matchingSubscriptions = subscriptions.data.filter((sub) => sub.items.data.some((item) => item.price.product === productId), ); totalSubscriptions += matchingSubscriptions.length; // Check if more pages of subscriptions exist hasMore = subscriptions.has_more; if (hasMore) { startingAfter = subscriptions.data[subscriptions.data.length - 1].id; } } return totalSubscriptions; } catch (err) { shell.error("Error fetching cumulative subscription count:", err); } } const MAX_LINES = 19; // π Home, Chat, Page Routes if ( (path === "/" || path === "/chat" || path === "/gate" || path === "/write" || path === "/ask" || path === "/respond" || path.match(/^\/\d+$/) || path.match(/^\/q\d+$/)) && method === "get" ) { const miniBreakpoint = 245; let title = "Sotce Net"; if (path !== "/") { title = path.replace("/", "") + " Β· " + title; } const body = html`
`;
return respond(200, body, { "Content-Type": "text/html; charset=utf-8" });
} else if (path === "/subscribers" && method === "get") {
const subscribers = await getCumulativeSubscriptionCount(productId);
if (subscribers !== undefined && subscribers !== null) {
return respond(200, { subscribers });
} else {
return respond(500, { message: "Could not get subscriber count." });
}
} else if (path === "/subscribe" && method === "post") {
try {
const stripe = Stripe(key);
const redirectPath =
event.headers.origin === "https://sotce.net" ? "" : "sotce-net";
shell.log(
"πΊοΈ Origin:",
event.headers.origin,
"redirectPath:",
redirectPath,
);
const { email, sub } = JSON.parse(event.body);
// Search for the customer by the metadata field 'sub'
const customers = await stripe.customers.search({
query: `metadata['sub']:'${sub}'`,
});
let customer;
if (customers.data.length > 0) {
// Customer found, optionally update metadata
// customer = await stripe.customers.update(customers.data[0].id, {
// metadata: { sub },
// });
customer = customers.data[0];
// π‘οΈ Check if the customer already has an active sotce-net subscription
// This prevents duplicate subscriptions from being created (bug fix 2025.12.03)
const existingSubscriptions = await stripe.subscriptions.list({
customer: customer.id,
status: "active",
limit: 10,
});
const hasActiveSotceNetSub = existingSubscriptions.data.some((sub) =>
sub.items.data.some((item) => item.price.product === productId),
);
if (hasActiveSotceNetSub) {
shell.log(
"β οΈ Customer already has an active sotce-net subscription:",
customer.id,
);
return respond(400, {
message: "You already have an active subscription.",
alreadySubscribed: true,
});
}
} else {
// Customer doesn't exist, create a new one
customer = await stripe.customers.create({
email: email,
metadata: { sub },
});
}
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
customer: customer.id, // Attach the existing or newly created customer
success_url: `${event.headers.origin}/${redirectPath}?notice=success`,
cancel_url: `${event.headers.origin}/${redirectPath}?notice=cancel`,
// metadata: { sub },
});
return respond(200, { id: session.id });
} catch (error) {
shell.log("β οΈ", error);
return respond(500, { message: `Error: ${error.message}` });
}
} else if (path === "/subscribed" && method === "post") {
// First validate that the user has an active session via auth0.
// TODO: To properly handle rate limits on the `/userinfo` endpoint
// I should probably be sending the user info up in the POST
// request here and then the `authorize` function should
// be rewritten to use a different endpoint which has
// a more apt rate limit?
// Also it's possible that finding the subscription information here
// via Stripe should / could be cached in redis for faster
// retrieval later? 24.08.24.19.22
const body = JSON.parse(event.body); // Make sure we can parse the body.
const retrieve = body.retrieve || "subscription";
const user = await authorize(event.headers, "sotce");
if (!user) {
return respond(401, { message: "Unauthorized." });
}
const subscription = await subscribed(user);
if (subscription === null) {
return respond(500, { error: "Failed to fetch subscription status" });
}
if (subscription?.subscribed === false || !subscription) {
return respond(200, { subscribed: false });
}
if (subscription?.status === "active") {
// What did we need the subscription for?
const out = { subscribed: true };
// Include both pages and the subscription state if retrieving 'everything'.
if (retrieve === "everything") {
shell.log("π« Retrieving pages...", performance.now());
// π Subscription status. (until, renews)
out.until = subscription.cancel_at_period_end
? new Date(subscription.cancel_at * 1000).toLocaleDateString(
"en-US",
dateOptions,
)
: "recurring";
if (out.until === "recurring") {
out.renews = new Date(subscription.current_period_end * 1000).toLocaleDateString("en-US", dateOptions);
}
// πΈ Administrator status.
// Skip hasAdmin call if using admin_bypass (already verified admin)
const isAdmin = subscription.admin_bypass ? true : await hasAdmin(user, "sotce");
if (isAdmin) out.admin = isAdmin;
shell.log("π΄ Admin:", isAdmin);
// π Recent Pages (with pagination support)
const database = await connect();
const pages = database.db.collection("sotce-pages");
// Pagination parameters
const requestedPage = body.pageNumber; // Specific page number (1-indexed)
const offset = body.offset || 0; // For loading older pages
const metaOnly = body.metaOnly; // Only return page count and last modified
// Always get total count and last modified for cache validation
// Exclude Q&A pages (isQA: true) β those are shown as question cards from sotce-asks
const pageFilter = { state: "published", isQA: { $ne: true } };
const totalCount = await pages.countDocuments(pageFilter);
// When loadAll is set, return the full history so scrollback reaches the very
// first page; otherwise fall back to a bounded window.
const limit = body.loadAll
? Math.max(totalCount, 1) // $limit must be positive
: Math.min(body.limit || 5, 500); // Default to 5, max 500 pages per request
shell.log("π Pagination: requestedPage=", requestedPage, "limit=", limit, "offset=", offset, "loadAll=", !!body.loadAll);
const lastModifiedDoc = await pages.findOne(
pageFilter,
{ sort: { updatedAt: -1 }, projection: { updatedAt: 1, when: 1 } }
);
out.totalPages = totalCount;
out.lastModified = lastModifiedDoc?.updatedAt || lastModifiedDoc?.when || null;
if (metaOnly) {
await database.disconnect();
return respond(200, out);
}
let retrievedPages;
if (requestedPage !== undefined) {
// Fetch a specific page by its index (1-indexed)
retrievedPages = await pages
.aggregate([
{ $match: pageFilter },
{ $sort: { when: 1 } },
{ $skip: requestedPage - 1 },
{ $limit: 1 },
])
.toArray();
out.pageIndex = requestedPage;
} else {
// Fetch latest pages (from the end), with optional offset for loading older
retrievedPages = await pages
.aggregate([
{ $match: pageFilter },
{ $sort: { when: -1 } }, // Newest first
{ $skip: offset },
{ $limit: limit },
])
.toArray();
// Reverse to maintain chronological order
retrievedPages.reverse();
out.hasMore = offset + limit < totalCount;
}
// Add a 'handle' field to each page record.
const subsToHandles = {}; // Cache handles on this go around.
for (const [index, page] of retrievedPages.entries()) {
let handle = subsToHandles[page.user];
if (!handle) {
handle = await handleFor(page.user, "sotce");
if (handle) subsToHandles[page.user] = handle;
}
page.handle = handle;
}
out.pages = retrievedPages;
// β Also fetch answered questions to mix into the feed
const asks = database.db.collection("sotce-asks");
const answeredQuestions = await asks
.find({ state: "answered" })
.sort({ answeredAt: -1 })
.limit(body.loadAll ? 0 : 50) // 0 = no limit (full history) when loading all
.project({ draftAnswer: 0, draftStartedAt: 0, draftLastEditedAt: 0 })
.toArray();
// Add handles to questions
for (const q of answeredQuestions) {
let handle = subsToHandles[q.user];
if (!handle) {
handle = await handleFor(q.user, "sotce");
if (handle) subsToHandles[q.user] = handle;
}
q.handle = handle;
q.type = "question"; // Mark as question for client-side rendering
}
out.questions = answeredQuestions;
out.totalQuestions = await asks.countDocuments({ state: "answered" });
await database.disconnect();
// TODO: π€ 'Handled' pages filtered by user..
shell.log("π« Retrieved:", retrievedPages.length, "pages,", answeredQuestions.length, "questions", performance.now());
}
return respond(200, out);
} else {
return respond(200, { subscribed: false });
}
} else if (path === "/cancel" && method === "post") {
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Unauthorized." });
shell.log("User authorized. Cancelling subscription...");
const cancelResult = await cancelSubscription(user, key);
return respond(cancelResult.status, cancelResult.body);
} else if (path === "/write-a-page" && method === "post") {
// πͺ§ write-a-page - Submission endpoint.
// TODO: Make this path RESTful with alternate methods to represent the resource. 24.10.05.23.33
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
// TODO: π Add support for creating a draft.
const body = JSON.parse(event.body);
shell.log("πͺ§ Page to post:", body);
if (body.draft === "retrieve-or-create") {
const database = await connect();
const pages = database.db.collection("sotce-pages");
await pages.createIndex({ user: 1, state: 1 }); // Ensure 'user' and 'state' index.
// Try to get the last page from this user.sub where 'state' is 'draft'.
let page = await pages.findOne(
{ user: user.sub, state: "draft" },
{ sort: { when: -1 } },
);
// If that page does not exist, then insert a new one with the 'draft' state.
if (!page) {
const insertion = await pages.insertOne({
user: user.sub,
words: "",
when: new Date(),
state: "draft",
});
page = await pages.findOne({ _id: insertion.insertedId });
}
await database.disconnect();
return respond(200, { page });
} else if (body.draft === "keep") {
const database = await connect();
const pages = database.db.collection("sotce-pages");
// Try to get the last page from this user.sub where 'state' is 'draft'.
let page = await pages.findOne(
{ user: user.sub, state: "draft" },
{ sort: { when: -1 } },
);
// If the page exists, update the draft with the new content.
if (page) {
await pages.updateOne(
{ _id: page._id },
{ $set: { words: body.words } },
);
page = await pages.findOne({ _id: page._id });
}
// If no draft exists, create a new draft with the content.
else {
const insertion = await pages.insertOne({
user: user.sub,
words: body.words || "", // Use provided content or default to an empty string
when: new Date(),
state: "draft",
});
page = await pages.findOne({ _id: insertion.insertedId });
}
await database.disconnect();
return respond(200, { page });
} else if (body.draft === "crumple") {
// πͺοΈ Delete (crumple) the current draft.
const database = await connect();
const pages = database.db.collection("sotce-pages");
// See if there is a page id attached, otherwise look for the most
// recent draft...
let page;
if (body._id) {
page = await pages.findOne({ _id: new ObjectId(body._id) });
} else {
// Try to get the last page from this user.sub where 'state' is 'draft'.
page = await pages.findOne(
{ user: user.sub, state: "draft" },
{ sort: { when: -1 } },
);
}
// If the page exists, update its state to 'crumpled' or delete it if body.words is empty/undefined.
if (page) {
if (
page.state === "draft" &&
(!body.words || body.words.length === 0)
) {
shell.log("β Permanently deleting page:", page._id);
await pages.deleteOne({ _id: page._id });
} else {
const updates = { state: "crumpled" };
if (body.words) updates.words = body.words;
await pages.updateOne({ _id: page._id }, { $set: updates });
}
await database.disconnect();
return respond(200, { message: "Draft crumpled successfully." });
} else {
await database.disconnect();
return respond(500, { message: "No page found to crumple." });
}
// Actually just set the state to 'crumpled' here.
} else if (body.draft) {
return respond(500, { message: "Invalid drafting option." });
}
// π‘ TODO: Eventually create a 'books' abstraction so users can have
// multiple books that capture groupings of pages. 24.09.13.01.41
const words = body.words;
if (words) {
const database = await connect();
const pages = database.db.collection("sotce-pages");
// Find the existing draft for the user.
let page = await pages.findOne(
{ user: user.sub, state: "draft" },
{ sort: { when: -1 } },
);
// If a draft exists, update it with the new words and set the state to 'published'.
if (page) {
await pages.updateOne(
{ _id: page._id },
{ $set: { words, state: "published" } },
);
} else {
// If no draft exists, insert a new page. (Edge case where the
// date would be updated in a new page on a double save / overwrite)
const insertion = await pages.insertOne({
user: user.sub,
words,
when: new Date(),
state: "published",
});
page = await pages.findOne({ _id: insertion.insertedId });
}
await database.disconnect();
return respond(200, { page });
} else {
return respond(500, { message: "No words written." });
}
} else if (path === "/touch-a-page" && method === "post") {
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Unauthorized." });
// Make sure the user is subscribed before they can touch a page.
const subscription = await subscribed(user);
if (!subscription || subscription.status !== "active") {
return respond(500, { message: "User not subscribed." });
}
const body = JSON.parse(event.body);
shell.log("π Page to touch:", body);
const id = new ObjectId(body._id);
const database = await connect();
const pages = database.db.collection("sotce-pages");
const page = await pages.findOne({ _id: id });
// console.log("π Page:", page, id);
if (page) {
const touches = database.db.collection("sotce-touches");
// Try to touch the page.
if (page.user !== user.sub) {
// Don't let users touch pages they created.
await touches.createIndex({ user: 1, page: 1 }, { unique: true });
try {
// Insert touch, assuming 'user.sub' contains the user's sub identifier
await touches.insertOne({
user: user.sub, // User's sub identifier
page: id, // Page ID from the request body
when: new Date(), // Current date and time
});
} catch (error) {
if (error.code === 11000) {
// Duplicate key error, meaning the user has already touched this page
console.log("User has already touched this page.");
} else {
console.error(
"An error occurred while touching the page:",
error.message,
);
}
}
}
// Fetch all touches for the page, even if an error occurred ot a touch did not happen.
const pageTouches = await touches.find({ page: id }).toArray();
// Add a 'handle' field to each touch record.
const handles = [];
for (const [index, touch] of pageTouches.entries()) {
// if (touch.user === user.sub) continue;
const handle = await handleFor(touch.user, "sotce"); // Cross-network handle request.
if (handle) {
handles.push("@" + handle);
} // else {
// TODO: Eventually track other touches?
// }
}
await database.disconnect();
return respond(200, { touches: handles });
} else {
await database.disconnect();
return respond(404, { message: "No page found to touch." });
}
} else if (path === "/delete-account" && method === "post") {
// See also the 'delete-erase-and-forget-me.js' function for aesthetic users.
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Authorization failure..." });
shell.log("π΄ Deleting user:", user.sub);
const sub = user.sub;
const sotceSub = "sotce-" + sub;
// 1. Unsubscribe the user if they have an active subscription.
try {
const cancelResult = await cancelSubscription(user, key);
shell.log(
"β Cancelled subscription?",
cancelResult.status,
cancelResult.body,
);
} catch (err) {
shell.error("π΄ Subscription cancellation error:", err);
}
// 2. Delete any user data, like posts.
const database = await connect();
// π¨οΈ Clear any chat messages owned by the user.
// Rewrite the "text" field to be null / empty and rewrite the user field to be empty
// rather than simply deleting the records associated with the user sub.
await database.db
.collection("chat-sotce")
.updateMany({ user: sub }, { $set: { text: "", user: "" } });
console.log("π§ Erased chats.");
// Remove the user's handle cache from redis.
const handle = await getHandleOrEmail(sotceSub);
if (handle?.startsWith("@")) {
await KeyValue.connect();
await KeyValue.del("@handles", handle);
await KeyValue.del("userIDs", sotceSub);
await KeyValue.disconnect();
}
// 3. Delete the user's handle if it exists and the user does not have
// an aesthetic computer account, otherwise re-associate the key.
if (handle) {
shell.log(
"π Checking for any `aesthetic` user with the same email and handle:",
handle,
);
const bareHandle = handle.slice(1); // Remove the "@" from the handle.
const idRes = await userIDFromEmail(user.email, "aesthetic");
if (idRes?.userID && idRes?.email_verified) {
const handles = database.db.collection("@handles");
const aestheticSub = idRes.userID;
// Check if an entry with the same _id already exists
const existingHandle = await handles.findOne({ _id: aestheticSub });
if (!existingHandle) {
// If no existing entry, proceed with deletion and insertion
await handles.deleteOne({ _id: sotceSub });
await handles.insertOne({ _id: aestheticSub, handle: bareHandle });
shell.log(
"π§ Changed primary handle key of 'sotce' user:",
sub,
"to 'aesthetic' user:",
aestheticSub,
);
} else {
// If an entry already exists, skip deletion and insertion
shell.log(
"π©Ή Handle already native to `aesthetic`, skipping reassignment.",
);
}
} else {
await database.db.collection("@handles").deleteOne({ _id: sotceSub });
shell.log("π§ Deleted user handle for:", sotceSub);
}
}
shell.log("β Deleted database data.");
await database.disconnect();
// 3. Delete the user's auth0 account.
const deleted = await deleteUser(sub, "sotce");
shell.log("β Deleted user registration:", deleted, user.email);
return respond(200, { result: "Deleted!" }); // Successful account deletion.
} else if (path === "/ask" && method === "post") {
// β Submit a question
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Unauthorized." });
const subscription = await subscribed(user);
if (!subscription || subscription.status !== "active") {
return respond(403, { message: "Subscription required." });
}
const body = JSON.parse(event.body);
const question = body.question?.trim();
if (!question || question.length === 0) {
return respond(400, { message: "Question cannot be empty." });
}
if (question.length > 500) {
return respond(400, { message: "Question too long (max 500 chars)." });
}
const database = await connect();
const asks = database.db.collection("sotce-asks");
// Look up the user's handle to store with the question
const askerHandle = await handleFor(user.sub, "sotce");
const insertion = await asks.insertOne({
user: user.sub,
handle: askerHandle || null,
question,
when: new Date(),
state: "pending",
});
await database.disconnect();
shell.log("β Question submitted:", insertion.insertedId);
return respond(200, { _id: insertion.insertedId });
} else if (path === "/asks" && method === "get") {
// β Get user's own questions
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Unauthorized." });
const database = await connect();
const asks = database.db.collection("sotce-asks");
const userAsks = await asks.find({ user: user.sub })
.sort({ when: -1 })
.limit(50)
.project({ draftAnswer: 0, answer: 0, answeredBy: 0 }) // Don't expose answers to users yet
.toArray();
await database.disconnect();
return respond(200, { asks: userAsks });
} else if (path === "/asks/pending" && method === "get") {
// β Get pending questions (admin only)
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
const database = await connect();
const asks = database.db.collection("sotce-asks");
const pending = await asks.find({ state: "pending" })
.sort({ when: 1 })
.limit(100)
.toArray();
// Resolve handles for questions that don't have one stored
for (const q of pending) {
if (!q.handle && q.user) {
const h = await handleFor(q.user, "sotce");
if (h) q.handle = h;
}
}
await database.disconnect();
return respond(200, { asks: pending });
} else if (path.match(/^\/ask\/[a-f0-9]+\/respond$/) && method === "post") {
// β Respond to a question (admin only)
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
const askId = path.split("/")[2];
if (!askId) return respond(400, { message: "Missing question ID." });
const { answer } = JSON.parse(event.body || "{}");
if (!answer || !answer.trim()) {
return respond(400, { message: "Response cannot be empty." });
}
if (answer.length > 2000) {
return respond(400, { message: "Response too long (max 2000 chars)." });
}
const database = await connect();
const asks = database.db.collection("sotce-asks");
// Find the question
const question = await asks.findOne({ _id: new ObjectId(askId) });
if (!question) {
await database.disconnect();
return respond(404, { message: "Question not found." });
}
// Update the question with the answer
const result = await asks.updateOne(
{ _id: new ObjectId(askId) },
{
$set: {
state: "answered",
answer: answer.trim(),
answeredBy: user.sub,
answeredAt: new Date().toISOString(),
},
}
);
if (result.modifiedCount === 0) {
await database.disconnect();
return respond(500, { message: "Could not save response." });
}
// NOTE: No separate page is created β answered questions live only in
// sotce-asks and get swizzled into the feed client-side alongside diary pages.
await database.disconnect();
shell.log("β Question answered:", askId, "by", user.email);
return respond(200, { success: true, askId });
} else if (path.match(/^\/ask\/[a-f0-9]+\/save-draft$/) && method === "post") {
// β Save a draft response (admin only) - also marks draftStartedAt
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
const askId = path.split("/")[2];
if (!askId) return respond(400, { message: "Missing question ID." });
const { draft } = JSON.parse(event.body || "{}");
const database = await connect();
const asks = database.db.collection("sotce-asks");
const question = await asks.findOne({ _id: new ObjectId(askId) });
if (!question) {
await database.disconnect();
return respond(404, { message: "Question not found." });
}
const updateFields = {
draftLastEditedAt: new Date().toISOString(),
};
if (!question.draftStartedAt) {
updateFields.draftStartedAt = new Date().toISOString();
}
if (draft !== undefined) {
updateFields.draftAnswer = draft;
}
await asks.updateOne(
{ _id: new ObjectId(askId) },
{ $set: updateFields }
);
await database.disconnect();
shell.log("β Draft saved for:", askId, "by", user.email);
return respond(200, { success: true, askId });
} else if (path.match(/^\/ask\/[a-f0-9]+\/reject$/) && method === "post") {
// β Reject a question (admin only)
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
const askId = path.split("/")[2];
if (!askId) return respond(400, { message: "Missing question ID." });
const database = await connect();
const asks = database.db.collection("sotce-asks");
// Find the question
const question = await asks.findOne({ _id: new ObjectId(askId) });
if (!question) {
await database.disconnect();
return respond(404, { message: "Question not found." });
}
// Update the question state to rejected
const result = await asks.updateOne(
{ _id: new ObjectId(askId) },
{
$set: {
state: "rejected",
rejectedBy: user.sub,
rejectedAt: new Date().toISOString(),
},
}
);
await database.disconnect();
if (result.modifiedCount === 0) {
return respond(500, { message: "Could not reject question." });
}
shell.log("β Question rejected:", askId, "by", user.email);
return respond(200, { success: true, askId });
} else if (path === "/asks/clear-all" && method === "delete") {
// β Clear all questions (admin only) - for development/reset
const user = await authorize(event.headers, "sotce");
const isAdmin = await hasAdmin(user, "sotce");
if (!user || !isAdmin) return respond(401, { message: "Unauthorized." });
const database = await connect();
const asks = database.db.collection("sotce-asks");
const result = await asks.deleteMany({});
await database.disconnect();
shell.log("β All questions cleared:", result.deletedCount, "by", user.email);
return respond(200, { success: true, deletedCount: result.deletedCount });
} else if (path.match(/^\/ask\/[a-f0-9]+$/) && method === "delete") {
// β Delete own pending question (only if no draft started by @amelia)
const user = await authorize(event.headers, "sotce");
if (!user) return respond(401, { message: "Unauthorized." });
const askId = path.replace("/ask/", "");
if (!askId) return respond(400, { message: "Missing question ID." });
const database = await connect();
const asks = database.db.collection("sotce-asks");
// Only allow deletion if: owned by user, still pending, and no draft started
const question = await asks.findOne({ _id: new ObjectId(askId) });
if (!question) {
await database.disconnect();
return respond(404, { message: "Question not found." });
}
if (question.user !== user.sub) {
await database.disconnect();
return respond(403, { message: "Not your question." });
}
if (question.state !== "pending") {
await database.disconnect();
return respond(400, { message: "Cannot delete β already answered." });
}
if (question.draftStartedAt) {
await database.disconnect();
return respond(400, { message: "Cannot delete β @amelia has started drafting a response." });
}
const result = await asks.deleteOne({ _id: new ObjectId(askId) });
await database.disconnect();
shell.log("β Question deleted:", askId, "by", user.sub);
return respond(200, { deleted: true });
} else if (path === "/privacy-policy" && method === "get") {
const subscribers = await getActiveSubscriptionCount(productId);
const body = html`
Sotce Net keeps pages on a server for subscribers to read.
You can associate an email with a @handle to represent your identity.
We use cookies and third-party services for login, analytics, and payments.
We federate handles with Aesthetic Computer β same email means shared @handle.
We do not sell your data.
Delete your account from the settings page. Write to mail@sotce.net with questions.
Brought to you by Sotce and Aesthetic Computer.
Sotce Net has " + subscribers + " active subscriber" + (subscribers > 1 ? "s" : "") + ".