diff --git a/.env b/.env index ccbffc15e..f5ea56344 100644 --- a/.env +++ b/.env @@ -14,7 +14,7 @@ MONGODB_URL= # To send NostR notifications for order status changes, specify the following NOSTR_PRIVATE_KEY=#nsec1XXXXXXXXXXXXXXXXX # The url of your app -ORIGIN= +ORIGIN=http://localhost:5173 # Name of the S3 bucket S3_BUCKET= # Eg http://s3-website.us-east-1.amazonaws.com OR http://s3.fr-par.scw.cloud diff --git a/README.md b/README.md index 2b9461db7..f3fee3589 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Add `.env.local` or `.env.{development,test,production}.local` files for secrets - `MONGODB_URL` - The connection URL to the MongoDB replicaset - `MONGODB_DB` - The DB name, defaulting to "bootik" - `NOSTR_PRIVATE_KEY` - To send notifications +- `ORIGIN` - The url of the bootik. For example, https://dev-bootik.pvh-labs.ch - `S3_BUCKET` - The name of the bucket for the S3-compatible object storage - `S3_ENDPOINT` - The endpoint for the S3-compatible object storage - eg http://s3-website.us-east-1.amazonaws.com or http://s3.fr-par.scw.cloud - `S3_KEY_ID` - Credentials for the S3-compatible object storage diff --git a/src/hooks.server.ts b/src/hooks.server.ts index cf618eef3..f66d1c77d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,6 +8,7 @@ import '$lib/server/currency-lock'; import '$lib/server/order-lock'; import '$lib/server/order-notifications'; import '$lib/server/nostr-notifications'; +import '$lib/server/handle-messages'; export const handleError = (({ error, event }) => { console.error('handleError', error); diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index f26d5320a..e03ae33da 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -9,6 +9,7 @@ import type { DigitalFile } from '$lib/types/DigitalFile'; import type { Order } from '$lib/types/Order'; import type { NostRNotification } from '$lib/types/NostRNotifications'; import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage'; +import type { Subscription } from '$lib/types/Subscription'; const client = new MongoClient(MONGODB_URL, { // directConnection: true @@ -21,6 +22,7 @@ const db = client.db(MONGODB_DB); // const users = db.collection('users'); const pictures = db.collection('pictures'); const products = db.collection('products'); +const subscriptions = db.collection('subscriptions'); const carts = db.collection('carts'); const runtimeConfig = db.collection('runtimeConfig'); const locks = db.collection('locks'); @@ -44,7 +46,8 @@ export const collections = { pendingDigitalFiles, orders, nostrNotifications, - nostrReceivedMessages + nostrReceivedMessages, + subscriptions }; export function transaction(dbTransactions: WithSessionCallback): Promise { @@ -56,9 +59,15 @@ client.on('open', () => { locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 60 }); carts.createIndex({ sessionId: 1 }, { unique: true }); orders.createIndex({ sessionId: 1 }); + orders.createIndex( + { 'notifications.paymentStatus.npub': 1, createdAt: -1 }, + { partialFilterExpression: { 'notifications.paymentStatus.npub': { $exists: true } } } + ); orders.createIndex({ number: 1 }, { unique: true }); digitalFiles.createIndex({ productId: 1 }); nostrReceivedMessages.createIndex({ createdAt: -1 }); + nostrNotifications.createIndex({ dest: 1 }); + subscriptions.createIndex({ npub: 1 }, { sparse: true }); }); export async function withTransaction(cb: WithSessionCallback) { diff --git a/src/lib/server/handle-messages.ts b/src/lib/server/handle-messages.ts new file mode 100644 index 000000000..b66d91412 --- /dev/null +++ b/src/lib/server/handle-messages.ts @@ -0,0 +1,156 @@ +import { ObjectId, type ChangeStreamDocument } from 'mongodb'; +import { collections } from './database'; +import { Lock } from './lock'; +import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage'; +import { Kind } from 'nostr-tools'; +import { ORIGIN } from '$env/static/private'; +import { runtimeConfig } from './runtime-config'; +import { SATOSHIS_PER_BTC } from '$lib/types/Currency'; + +const lock = new Lock('received-messages'); + +// todo: resume changestream on restart if possible +collections.nostrReceivedMessages + .watch([{ $match: { operationType: 'insert' } }], { + fullDocument: 'updateLookup' + }) + .on('change', (ev) => handleChanges(ev).catch(console.error)); + +async function handleChanges(change: ChangeStreamDocument): Promise { + if (!lock.ownsLock || !('fullDocument' in change) || !change.fullDocument) { + return; + } + + if (change.fullDocument.processedAt) { + return; + } + + const content = change.fullDocument.content; + const senderNpub = change.fullDocument.source; + + const isCustomer = + (await collections.nostrNotifications.countDocuments({ dest: senderNpub }, { limit: 1 })) > 0; + const isPrivateMessage = change.fullDocument.kind === Kind.EncryptedDirectMessage; + + switch (content.trim().replaceAll(/\s+/g, ' ')) { + case 'help': + await sendMessage( + senderNpub, + `Commands: + +- orders: Show the list of orders associated to your npub +- catalog: Show the catalog +- detailed catalog: Show the catalog, with product descriptions +- subscribe: Subscribe to catalog updates +- unsubscribe: Unsubscribe from catalog updates` + ); + break; + case 'orders': { + const orders = await collections.orders + .find({ 'notifications.paymentStatus.npub': senderNpub }) + .sort({ createdAt: -1 }) + .limit(100) + .toArray(); + + if (orders.length) { + await sendMessage( + senderNpub, + orders.map((order) => `- #${order.number}: ${ORIGIN}/order/${order._id}`).join('\n') + ); + } else { + await sendMessage(senderNpub, 'No orders found for your npub'); + } + + break; + } + case 'detailed catalog': + case 'catalog': { + if (!runtimeConfig.discovery) { + await sendMessage( + senderNpub, + 'Discovery is not enabled for this bootik. You cannot access the catalog.' + ); + } else { + const products = await collections.products.find({}).toArray(); + + if (!products.length) { + await sendMessage(senderNpub, 'Catalog is empty'); + } else { + // todo: proper price dependinc on currency + await sendMessage( + senderNpub, + products + .map( + (product) => + `- ${product.name} / ${Math.round( + product.price.amount * SATOSHIS_PER_BTC + )} SAT / ${ORIGIN}/product/${product._id}${ + content === 'detailed catalog' + ? ` / ${product.shortDescription.replaceAll(/\s+/g, ' ')}` + : '' + }` + ) + .join('\n') + ); + } + } + break; + } + case 'subscribe': + if (!runtimeConfig.discovery) { + await sendMessage( + senderNpub, + 'Discovery is not enabled for the bootik, you cannot subscribe' + ); + } else { + await collections.subscriptions.updateOne( + { npub: senderNpub }, + { + $set: { + updatedAt: new Date() + }, + $setOnInsert: { + createdAt: new Date() + } + }, + { upsert: true } + ); + await sendMessage( + senderNpub, + 'You are subscribed to the catalog, you will receive messages when new products are added' + ); + } + break; + case 'unsubscribe': { + const result = await collections.subscriptions.deleteOne({ npub: senderNpub }); + + if (result.deletedCount) { + await sendMessage(senderNpub, 'You were unsubscribed from the catalog'); + } else { + await sendMessage(senderNpub, 'You were already unsubscribed from the catalog'); + } + break; + } + default: + await sendMessage( + senderNpub, + `Hello ${ + !isPrivateMessage ? 'world' : isCustomer ? 'customer' : 'you' + }! To get the list of commands, say 'help'.` + ); + } + await collections.nostrReceivedMessages.updateOne( + { _id: change.fullDocument._id }, + { $set: { processedAt: new Date(), updatedAt: new Date() } } + ); +} + +function sendMessage(dest: string, content: string) { + return collections.nostrNotifications.insertOne({ + dest, + _id: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + content + }); +} diff --git a/src/lib/server/order-notifications.ts b/src/lib/server/order-notifications.ts index 962ba2230..7fb5f79ac 100644 --- a/src/lib/server/order-notifications.ts +++ b/src/lib/server/order-notifications.ts @@ -2,6 +2,7 @@ import type { Order } from '$lib/types/Order'; import { ObjectId, type ChangeStreamDocument } from 'mongodb'; import { collections } from './database'; import { Lock } from './lock'; +import { ORIGIN } from '$env/static/private'; const lock = new Lock('order-notifications'); @@ -52,13 +53,15 @@ async function handleChanges(change: ChangeStreamDocument): Promise return; } - const { npub } = change.fullDocument.notifications.paymentStatus; + const order = change.fullDocument; + + const { npub } = order.notifications.paymentStatus; await collections.nostrNotifications.insertOne({ _id: new ObjectId(), createdAt: new Date(), updatedAt: new Date(), - content: `Order #${change.fullDocument.number} ${change.fullDocument.payment.status}, see ${change.fullDocument.url}`, + content: `Order #${order.number} ${order.payment.status}, see ${ORIGIN}/order/${order._id}`, dest: npub }); } diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index 4a85b9119..e95ae70e8 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -5,7 +5,8 @@ const defaultConfig = { BTC_EUR: 30_000, orderNumber: 0, - checkoutButtonOnProductPage: true + checkoutButtonOnProductPage: true, + discovery: true }; export type RuntimeConfig = typeof defaultConfig; diff --git a/src/lib/types/Order.ts b/src/lib/types/Order.ts index 2bd786a77..d672b4cae 100644 --- a/src/lib/types/Order.ts +++ b/src/lib/types/Order.ts @@ -10,10 +10,6 @@ export interface Order extends Timestamps { _id: string; sessionId: string; - // Save URL from where the order was created, because no other way to get domain name for now - // other than through the request object - url: string; - number: number; items: Array<{ diff --git a/src/lib/types/Subscription.ts b/src/lib/types/Subscription.ts new file mode 100644 index 000000000..02a466b96 --- /dev/null +++ b/src/lib/types/Subscription.ts @@ -0,0 +1,8 @@ +import type { ObjectId } from 'mongodb'; +import type { Timestamps } from './Timestamps'; + +export interface Subscription extends Timestamps { + _id: ObjectId; + + npub: string; +} diff --git a/src/routes/admin/config/+page.server.ts b/src/routes/admin/config/+page.server.ts index dea948957..c408deeb2 100644 --- a/src/routes/admin/config/+page.server.ts +++ b/src/routes/admin/config/+page.server.ts @@ -1,10 +1,13 @@ +import { ORIGIN } from '$env/static/private'; import { collections } from '$lib/server/database.js'; import { runtimeConfig } from '$lib/server/runtime-config'; import { z } from 'zod'; export async function load() { return { - checkoutButtonOnProductPage: runtimeConfig.checkoutButtonOnProductPage + checkoutButtonOnProductPage: runtimeConfig.checkoutButtonOnProductPage, + discovery: runtimeConfig.discovery, + origin: ORIGIN }; } @@ -14,10 +17,12 @@ export const actions = { const result = z .object({ - checkoutButtonOnProductPage: z.boolean({ coerce: true }) + checkoutButtonOnProductPage: z.boolean({ coerce: true }), + discovery: z.boolean({ coerce: true }) }) .parse({ - checkoutButtonOnProductPage: formData.get('checkoutButtonOnProductPage') + checkoutButtonOnProductPage: formData.get('checkoutButtonOnProductPage'), + discovery: formData.get('discovery') }); if (runtimeConfig.checkoutButtonOnProductPage !== result.checkoutButtonOnProductPage) { @@ -28,5 +33,13 @@ export const actions = { { upsert: true } ); } + if (runtimeConfig.discovery !== result.discovery) { + runtimeConfig.discovery = result.discovery; + await collections.runtimeConfig.updateOne( + { _id: 'discovery' }, + { $set: { data: result.discovery, updatedAt: new Date() } }, + { upsert: true } + ); + } } }; diff --git a/src/routes/admin/config/+page.svelte b/src/routes/admin/config/+page.svelte index ded2a461f..e7f34badd 100644 --- a/src/routes/admin/config/+page.svelte +++ b/src/routes/admin/config/+page.svelte @@ -3,6 +3,9 @@

Config

+ +

Configured URL: {data.origin}

+
+
diff --git a/src/routes/admin/product/new/+page.server.ts b/src/routes/admin/product/new/+page.server.ts index 1b4733c56..abcfcc939 100644 --- a/src/routes/admin/product/new/+page.server.ts +++ b/src/routes/admin/product/new/+page.server.ts @@ -7,6 +7,9 @@ import busboy from 'busboy'; import { streamToBuffer } from '$lib/server/utils/streamToBuffer'; import { error, redirect } from '@sveltejs/kit'; import { z } from 'zod'; +import { ObjectId } from 'mongodb'; +import { ORIGIN } from '$env/static/private'; +import { runtimeConfig } from '$lib/server/runtime-config'; export const actions: Actions = { default: async ({ request }) => { @@ -112,6 +115,28 @@ export const actions: Actions = { } }); + // This could be a change stream on collections.product, but for now a bit simpler + // to put it here. + // Later, if we have more notification types or more places where a product can be created, + // a change stream would probably be better + if (runtimeConfig.discovery) { + (async function () { + for await (const subscription of collections.subscriptions.find({ + npub: { $exists: true } + })) { + await collections.nostrNotifications + .insertOne({ + _id: new ObjectId(), + dest: subscription.npub, + content: `New product "${parsed.name}": ${ORIGIN}/product/${productId}`, + createdAt: new Date(), + updatedAt: new Date() + }) + .catch(console.error); + } + })().catch(console.error); + } + throw redirect(303, '/admin/product/' + productId); } }; diff --git a/src/routes/checkout/+page.server.ts b/src/routes/checkout/+page.server.ts index 6c48563c5..842f3f313 100644 --- a/src/routes/checkout/+page.server.ts +++ b/src/routes/checkout/+page.server.ts @@ -8,6 +8,7 @@ import { error, redirect } from '@sveltejs/kit'; import { addHours, differenceInSeconds } from 'date-fns'; import { z } from 'zod'; import { bech32 } from 'bech32'; +import { ORIGIN } from '$env/static/private'; export function load() { return { @@ -122,7 +123,6 @@ export const actions = { await collections.orders.insertOne( { _id: orderId, - url: `${new URL(request.url).origin}/order/${orderId}`, number: orderNumber, sessionId: locals.sessionId, createdAt: new Date(), @@ -145,7 +145,7 @@ export const actions = { const invoice = await lndCreateInvoice( Math.floor(total * SATOSHIS_PER_BTC), differenceInSeconds(expiresAt, new Date()), - `${new URL(request.url).origin}/order/${orderId}` + `${ORIGIN}/order/${orderId}` ); return {