Skip to content
Open
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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@ REDIS_URL=
OPENAI_API_KEY=

# Messaging
XMTP_ENV=dev
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=
13 changes: 13 additions & 0 deletions apps/backend/drizzle/0009_push_subscriptions.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions apps/backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"when": 1783000000000,
"tag": "0008_extend_messages",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1751140955086,
"tag": "0009_push_subscriptions",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"postgres": "^3.4.9",
"redis": "^6.0.0",
"socket.io": "^4.8.3",
"web-push": "3.6.7",
"zod": "^4.4.3"
},
"devDependencies": {
Expand All @@ -50,6 +51,7 @@
"@types/morgan": "^1.9.10",
"@types/node": "^20.19.37",
"@types/supertest": "^7.2.0",
"@types/web-push": "3.6.4",
"@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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'),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ export const pushSubscriptions = pgTable('push_subscriptions', {
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
lastUsedAt: timestamp('last_used_at'),
disabledAt: timestamp('disabled_at'),
});

export type PushSubscription = typeof pushSubscriptions.$inferSelect;
Expand Down
27 changes: 25 additions & 2 deletions apps/backend/src/routes/push.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,21 +18,23 @@ pushRouter.post('/subscriptions', async (req: AuthRequest, res) => {
}

try {
// Upsert subscription
await db
.insert(pushSubscriptions)
.values({
deviceId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
lastUsedAt: new Date(),
})
.onConflictDoUpdate({
target: [pushSubscriptions.endpoint],
set: {
deviceId,
p256dh: keys.p256dh,
auth: keys.auth,
disabledAt: null,
lastUsedAt: new Date(),
},
});

Expand Down Expand Up @@ -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<void> {
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<void> {
await db
.update(pushSubscriptions)
.set({ disabledAt: new Date() })
.where(eq(pushSubscriptions.endpoint, endpoint));
}
4 changes: 3 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
env: {
NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? '',
},
};

export default nextConfig;
Loading