diff --git a/.env.example b/.env.example index b470eab..7312479 100644 --- a/.env.example +++ b/.env.example @@ -27,4 +27,11 @@ OBJECT_STORE_FORCE_PATH_STYLE=true OPENAI_API_KEY= # Messaging -XMTP_ENV=dev \ No newline at end of file +XMTP_ENV=dev + +# Push Notifications (VAPID) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@example.com +# Exposed to the browser via Next.js +NEXT_PUBLIC_VAPID_PUBLIC_KEY= \ No newline at end of file diff --git a/apps/backend/drizzle/0009_push_subscriptions.sql b/apps/backend/drizzle/0009_push_subscriptions.sql new file mode 100644 index 0000000..0b1f9b7 --- /dev/null +++ b/apps/backend/drizzle/0009_push_subscriptions.sql @@ -0,0 +1,13 @@ +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_used_at" timestamp, + "disabled_at" timestamp, + CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") +); +--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_device_id_user_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."user_devices"("id") ON DELETE cascade ON UPDATE no action; diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index cab146f..620d7bb 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -15,6 +15,9 @@ export const EnvSchema = z.object({ JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), PORT: z.coerce.number().int('PORT must be an integer').positive('PORT must be positive'), TOKEN_TRANSFER_CONTRACT_ID: z.string().min(1, 'TOKEN_TRANSFER_CONTRACT_ID is required'), + VAPID_PUBLIC_KEY: z.string().min(1, 'VAPID_PUBLIC_KEY is required'), + VAPID_PRIVATE_KEY: z.string().min(1, 'VAPID_PRIVATE_KEY is required'), + VAPID_SUBJECT: z.string().min(1, 'VAPID_SUBJECT is required'), S3_ENDPOINT: z.string().optional(), S3_REGION: z.string().optional(), S3_ACCESS_KEY_ID: z.string().optional(), diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index d96f67c..1209d6c 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -360,6 +360,8 @@ export const pushSubscriptions = pgTable('push_subscriptions', { lastUsedAt: timestamp('last_used_at'), disabledAt: timestamp('disabled_at'), createdAt: timestamp('created_at').notNull().defaultNow(), + lastUsedAt: timestamp('last_used_at'), + disabledAt: timestamp('disabled_at'), }); export type PushSubscription = typeof pushSubscriptions.$inferSelect; diff --git a/apps/backend/src/routes/push.ts b/apps/backend/src/routes/push.ts index 9205dfb..b4e31ca 100644 --- a/apps/backend/src/routes/push.ts +++ b/apps/backend/src/routes/push.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import type { IRouter } from 'express'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; import { pushSubscriptions } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; @@ -18,7 +18,6 @@ pushRouter.post('/subscriptions', async (req: AuthRequest, res) => { } try { - // Upsert subscription await db .insert(pushSubscriptions) .values({ @@ -26,6 +25,7 @@ pushRouter.post('/subscriptions', async (req: AuthRequest, res) => { endpoint, p256dh: keys.p256dh, auth: keys.auth, + lastUsedAt: new Date(), }) .onConflictDoUpdate({ target: [pushSubscriptions.endpoint], @@ -33,6 +33,8 @@ pushRouter.post('/subscriptions', async (req: AuthRequest, res) => { deviceId, p256dh: keys.p256dh, auth: keys.auth, + disabledAt: null, + lastUsedAt: new Date(), }, }); @@ -62,3 +64,24 @@ pushRouter.delete('/subscriptions', async (req: AuthRequest, res) => { res.status(500).json({ error: 'Failed to delete subscription' }); } }); + +/** + * Touch lastUsedAt for active (non-disabled) subscriptions by device. + * Called internally before sending a push notification. + */ +export async function touchSubscription(endpoint: string): Promise { + await db + .update(pushSubscriptions) + .set({ lastUsedAt: new Date() }) + .where(and(eq(pushSubscriptions.endpoint, endpoint), isNull(pushSubscriptions.disabledAt))); +} + +/** + * Mark a subscription as disabled (e.g. after a 410 Gone from the push service). + */ +export async function disableSubscription(endpoint: string): Promise { + await db + .update(pushSubscriptions) + .set({ disabledAt: new Date() }) + .where(eq(pushSubscriptions.endpoint, endpoint)); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 30a7faa..b8df440 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,9 @@ import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {/* config options here */}; +const nextConfig: NextConfig = { + env: { + NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? '', + }, +}; export default nextConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28ea15c..6beb9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7136,7 +7136,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -7151,7 +7151,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9