Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Answer to messages + add discovery config #90

Merged
merged 4 commits into from
May 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/server/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,7 @@ const db = client.db(MONGODB_DB);
// const users = db.collection<User>('users');
const pictures = db.collection<Picture>('pictures');
const products = db.collection<Product>('products');
const subscriptions = db.collection<Subscription>('subscriptions');
const carts = db.collection<Cart>('carts');
const runtimeConfig = db.collection<RuntimeConfigItem>('runtimeConfig');
const locks = db.collection<Lock>('locks');
Expand All @@ -44,7 +46,8 @@ export const collections = {
pendingDigitalFiles,
orders,
nostrNotifications,
nostrReceivedMessages
nostrReceivedMessages,
subscriptions
};

export function transaction(dbTransactions: WithSessionCallback): Promise<void> {
Expand All @@ -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) {
Expand Down
156 changes: 156 additions & 0 deletions src/lib/server/handle-messages.ts
Original file line number Diff line number Diff line change
@@ -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<NostRReceivedMessage>): Promise<void> {
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
});
}
7 changes: 5 additions & 2 deletions src/lib/server/order-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -52,13 +53,15 @@ async function handleChanges(change: ChangeStreamDocument<Order>): Promise<void>
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
});
}
3 changes: 2 additions & 1 deletion src/lib/server/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const defaultConfig = {
BTC_EUR: 30_000,
orderNumber: 0,

checkoutButtonOnProductPage: true
checkoutButtonOnProductPage: true,
discovery: true
};

export type RuntimeConfig = typeof defaultConfig;
Expand Down
4 changes: 0 additions & 4 deletions src/lib/types/Order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down
8 changes: 8 additions & 0 deletions src/lib/types/Subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ObjectId } from 'mongodb';
import type { Timestamps } from './Timestamps';

export interface Subscription extends Timestamps {
_id: ObjectId;

npub: string;
}
19 changes: 16 additions & 3 deletions src/routes/admin/config/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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
};
}

Expand All @@ -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) {
Expand All @@ -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 }
);
}
}
};
12 changes: 12 additions & 0 deletions src/routes/admin/config/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
</script>

<h1 class="text-3xl">Config</h1>

<p>Configured URL: {data.origin}</p>

<form method="post" class="flex flex-col gap-6">
<label class="flex gap-2 cursor-pointer items-center">
<input
Expand All @@ -13,5 +16,14 @@
/>
checkoutButtonOnProductPage
</label>
<label class="flex gap-2 cursor-pointer items-center">
<input
type="checkbox"
name="discovery"
class="form-checkbox rounded-sm cursor-pointer"
checked={data.discovery}
/>
discovery
</label>
<input type="submit" value="Update" class="btn btn-gray self-start" />
</form>
25 changes: 25 additions & 0 deletions src/routes/admin/product/new/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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);
}
};
Loading