From bee617c4935f646cab84eeadbbd27d0ab72f4859 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 01:17:58 -0500 Subject: [PATCH] chore(server): add Node.js backend with auth, questions, answers + update gitignore --- .gitignore | 1 + server/.env.example | 14 ++++++ server/.gitignore | 4 ++ server/Dockerfile | 29 +++++++++++ server/docker-compose.yml | 38 +++++++++++++++ server/package.json | 25 ++++++++++ server/src/config/firebase.ts | 33 +++++++++++++ server/src/index.ts | 36 ++++++++++++++ server/src/listeners/answerListener.ts | 66 ++++++++++++++++++++++++++ server/src/routes/health.ts | 9 ++++ server/src/routes/webhooks.ts | 39 +++++++++++++++ server/src/services/entitlement.ts | 34 +++++++++++++ server/src/services/fcm.ts | 41 ++++++++++++++++ server/src/types/index.ts | 32 +++++++++++++ server/tsconfig.json | 16 +++++++ 15 files changed, 417 insertions(+) create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/docker-compose.yml create mode 100644 server/package.json create mode 100644 server/src/config/firebase.ts create mode 100644 server/src/index.ts create mode 100644 server/src/listeners/answerListener.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/webhooks.ts create mode 100644 server/src/services/entitlement.ts create mode 100644 server/src/services/fcm.ts create mode 100644 server/src/types/index.ts create mode 100644 server/tsconfig.json diff --git a/.gitignore b/.gitignore index 1c0db87a..f3bb1bca 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ out/ # App module build app/build/ +SecurityReport.md diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 00000000..f5b0f383 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,14 @@ +# Firebase Admin SDK — download from Firebase Console > Project Settings > Service Accounts +FIREBASE_SERVICE_ACCOUNT_PATH=/run/secrets/firebase-service-account.json +# Or paste the JSON inline (useful for cloud envs): +# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...} + +# Your Firebase project ID +FIREBASE_PROJECT_ID=your-project-id + +# RevenueCat webhook shared secret +# Set this in RevenueCat Dashboard > Project > Integrations > Webhooks > Authorization header +REVENUECAT_WEBHOOK_SECRET=your-revenuecat-webhook-secret + +# Server port (default 8080) +PORT=8080 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..1d3db223 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +firebase-service-account.json diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 00000000..f4121a60 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# ── Production image ──────────────────────────────────────────────────────── +FROM node:20-alpine AS production + +ENV NODE_ENV=production +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=builder /app/dist ./dist + +# Non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +EXPOSE 8080 + +CMD ["node", "dist/index.js"] diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 00000000..d6788989 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,38 @@ +services: + server: + build: + context: . + target: production + ports: + - "8080:8080" + env_file: + - .env + secrets: + - firebase-service-account + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + + # Local dev only — hot-reload without rebuilding the image + server-dev: + build: + context: . + target: builder + command: npm run dev + ports: + - "8080:8080" + env_file: + - .env + secrets: + - firebase-service-account + volumes: + - ./src:/app/src:ro + profiles: + - dev + +secrets: + firebase-service-account: + file: ./firebase-service-account.json diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..7a0fc2e8 --- /dev/null +++ b/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "couples-connect-server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "express": "^4.19.2", + "firebase-admin": "^12.2.0", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.9", + "@types/node": "^20.14.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.5" + } +} diff --git a/server/src/config/firebase.ts b/server/src/config/firebase.ts new file mode 100644 index 00000000..f0e1ef6c --- /dev/null +++ b/server/src/config/firebase.ts @@ -0,0 +1,33 @@ +import * as admin from 'firebase-admin' +import * as fs from 'fs' + +let initialized = false + +export function initFirebase(): void { + if (initialized) return + + const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH + const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON + + let credential: admin.credential.Credential + + if (serviceAccountJson) { + const parsed = JSON.parse(serviceAccountJson) + credential = admin.credential.cert(parsed) + } else if (serviceAccountPath && fs.existsSync(serviceAccountPath)) { + credential = admin.credential.cert(serviceAccountPath) + } else { + // Falls back to Application Default Credentials (works on GCP/Cloud Run) + credential = admin.credential.applicationDefault() + } + + admin.initializeApp({ + credential, + projectId: process.env.FIREBASE_PROJECT_ID, + }) + + initialized = true +} + +export const db = (): FirebaseFirestore.Firestore => admin.firestore() +export const messaging = (): admin.messaging.Messaging => admin.messaging() diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..9bcdb851 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,36 @@ +import 'dotenv/config' +import express from 'express' +import helmet from 'helmet' +import morgan from 'morgan' +import { initFirebase } from './config/firebase' +import { startAnswerListener } from './listeners/answerListener' +import healthRouter from './routes/health' +import webhooksRouter from './routes/webhooks' + +initFirebase() + +const app = express() +const PORT = parseInt(process.env.PORT ?? '8080', 10) + +app.use(helmet()) +app.use(morgan('combined')) +app.use(express.json()) + +app.use('/health', healthRouter) +app.use('/webhooks', webhooksRouter) + +const server = app.listen(PORT, () => { + console.log(`[server] listening on port ${PORT}`) + startAnswerListener() +}) + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('[server] SIGTERM received, shutting down') + server.close(() => process.exit(0)) +}) + +process.on('SIGINT', () => { + console.log('[server] SIGINT received, shutting down') + server.close(() => process.exit(0)) +}) diff --git a/server/src/listeners/answerListener.ts b/server/src/listeners/answerListener.ts new file mode 100644 index 00000000..21101f6b --- /dev/null +++ b/server/src/listeners/answerListener.ts @@ -0,0 +1,66 @@ +import * as admin from 'firebase-admin' +import { db } from '../config/firebase' +import { sendPartnerAnsweredNotification } from '../services/fcm' +import { CoupleDoc, UserDoc } from '../types' + +export function startAnswerListener(): () => void { + console.log('[listener] watching answer writes...') + + // Watch every answer document across all couples and threads + const unsubscribe = db() + .collectionGroup('answers') + .onSnapshot( + async (snapshot) => { + const creates = snapshot.docChanges().filter((c) => c.type === 'added') + + for (const change of creates) { + const answerRef = change.doc.ref + // Path: couples/{coupleId}/question_threads/{threadId}/answers/{userId} + const threadRef = answerRef.parent.parent + const coupleRef = threadRef?.parent.parent + + if (!coupleRef || !threadRef) continue + + const answererId = answerRef.id + const coupleId = coupleRef.id + + try { + const coupleSnap = await coupleRef.get() + if (!coupleSnap.exists) continue + + const couple = coupleSnap.data() as CoupleDoc + const partnerId = couple.userIds.find((id) => id !== answererId) + if (!partnerId) continue + + // Only notify if partner has NOT already answered this thread + const partnerAnswerSnap = await threadRef + .collection('answers') + .doc(partnerId) + .get() + if (partnerAnswerSnap.exists) continue + + const [partnerSnap, answererSnap] = await Promise.all([ + db().collection('users').doc(partnerId).get(), + db().collection('users').doc(answererId).get(), + ]) + + const partner = partnerSnap.data() as UserDoc | undefined + const answerer = answererSnap.data() as UserDoc | undefined + + if (!partner?.fcmToken) continue + + const answererName = answerer?.displayName ?? 'Your partner' + await sendPartnerAnsweredNotification(partner.fcmToken, answererName) + console.log(`[listener] notified ${partnerId} that ${answererId} answered in couple ${coupleId}`) + } catch (err) { + console.error(`[listener] error processing answer ${answerRef.path}:`, err) + } + } + }, + (err) => { + console.error('[listener] answer snapshot error:', err) + } + ) + + return unsubscribe +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 00000000..d3714384 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,9 @@ +import { Router, Request, Response } from 'express' + +const router = Router() + +router.get('/', (_req: Request, res: Response) => { + res.json({ status: 'ok', ts: new Date().toISOString() }) +}) + +export default router diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts new file mode 100644 index 00000000..67f249ec --- /dev/null +++ b/server/src/routes/webhooks.ts @@ -0,0 +1,39 @@ +import { Router, Request, Response } from 'express' +import { syncEntitlement } from '../services/entitlement' +import { RevenueCatEvent } from '../types' + +const router = Router() + +function verifyRevenueCatSecret(req: Request): boolean { + const secret = process.env.REVENUECAT_WEBHOOK_SECRET + if (!secret) { + console.warn('[webhook] REVENUECAT_WEBHOOK_SECRET not set — skipping auth check') + return true + } + const auth = req.headers['authorization'] ?? '' + return auth === secret +} + +router.post('/revenuecat', async (req: Request, res: Response) => { + if (!verifyRevenueCatSecret(req)) { + res.status(401).json({ error: 'unauthorized' }) + return + } + + const body = req.body as RevenueCatEvent + if (!body?.event?.type || !body?.event?.app_user_id) { + res.status(400).json({ error: 'malformed payload' }) + return + } + + // Acknowledge immediately — RC retries if we don't respond within 10s + res.status(200).json({ received: true }) + + try { + await syncEntitlement(body) + } catch (err) { + console.error('[webhook] entitlement sync failed:', err) + } +}) + +export default router diff --git a/server/src/services/entitlement.ts b/server/src/services/entitlement.ts new file mode 100644 index 00000000..d370d078 --- /dev/null +++ b/server/src/services/entitlement.ts @@ -0,0 +1,34 @@ +import { db } from '../config/firebase' +import { RevenueCatEvent } from '../types' + +const PREMIUM_ACTIVE_TYPES = new Set([ + 'INITIAL_PURCHASE', + 'RENEWAL', + 'PRODUCT_CHANGE', + 'TRANSFER', + 'UNCANCELLATION', +]) + +const PREMIUM_REVOKED_TYPES = new Set([ + 'EXPIRATION', + 'CANCELLATION', + 'SUBSCRIBER_ALIAS', +]) + +export async function syncEntitlement(event: RevenueCatEvent): Promise { + const { type, app_user_id: uid } = event.event + + if (PREMIUM_ACTIVE_TYPES.has(type)) { + await db().collection('users').doc(uid).update({ hasPremium: true }) + console.log(`[entitlement] hasPremium=true for ${uid} (${type})`) + return + } + + if (PREMIUM_REVOKED_TYPES.has(type)) { + await db().collection('users').doc(uid).update({ hasPremium: false }) + console.log(`[entitlement] hasPremium=false for ${uid} (${type})`) + return + } + + console.log(`[entitlement] ignored event type: ${type}`) +} diff --git a/server/src/services/fcm.ts b/server/src/services/fcm.ts new file mode 100644 index 00000000..658a656c --- /dev/null +++ b/server/src/services/fcm.ts @@ -0,0 +1,41 @@ +import { messaging } from '../config/firebase' + +export async function sendPushToUser( + fcmToken: string, + title: string, + body: string, + data?: Record +): Promise { + await messaging().send({ + token: fcmToken, + notification: { title, body }, + data, + android: { + priority: 'high', + notification: { channelId: 'partner_activity' }, + }, + }) +} + +export async function sendPartnerAnsweredNotification( + partnerFcmToken: string, + partnerName: string +): Promise { + await sendPushToUser( + partnerFcmToken, + 'Your partner answered!', + `${partnerName} just answered today's question. Tap to see their answer.`, + { type: 'partner_answered' } + ) +} + +export async function sendStreakReminderNotification( + fcmToken: string +): Promise { + await sendPushToUser( + fcmToken, + "Don't break your streak!", + "You haven't answered today's question yet. Keep your streak alive!", + { type: 'streak_reminder' } + ) +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts new file mode 100644 index 00000000..105c36ba --- /dev/null +++ b/server/src/types/index.ts @@ -0,0 +1,32 @@ +export interface UserDoc { + uid: string + displayName: string + email?: string + coupleId?: string + fcmToken?: string + hasPremium: boolean + createdAt: FirebaseFirestore.Timestamp +} + +export interface CoupleDoc { + userIds: string[] + createdAt: FirebaseFirestore.Timestamp + streakCount: number + lastStreakAt?: FirebaseFirestore.Timestamp +} + +export interface AnswerDoc { + userId: string + answeredAt: FirebaseFirestore.Timestamp +} + +export interface RevenueCatEvent { + event: { + type: string + app_user_id: string + product_id: string + period_type?: string + expiration_at_ms?: number + is_family_share?: boolean + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}