chore(server): add Node.js backend with auth, questions, answers + update gitignore

This commit is contained in:
null 2026-06-16 01:17:58 -05:00
parent 1a33d4f2b9
commit bee617c493
15 changed files with 417 additions and 0 deletions

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ out/
# App module build
app/build/
SecurityReport.md

14
server/.env.example Normal file
View File

@ -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

4
server/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
firebase-service-account.json

29
server/Dockerfile Normal file
View File

@ -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"]

38
server/docker-compose.yml Normal file
View File

@ -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

25
server/package.json Normal file
View File

@ -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"
}
}

View File

@ -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()

36
server/src/index.ts Normal file
View File

@ -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))
})

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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<void> {
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}`)
}

View File

@ -0,0 +1,41 @@
import { messaging } from '../config/firebase'
export async function sendPushToUser(
fcmToken: string,
title: string,
body: string,
data?: Record<string, string>
): Promise<void> {
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<void> {
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<void> {
await sendPushToUser(
fcmToken,
"Don't break your streak!",
"You haven't answered today's question yet. Keep your streak alive!",
{ type: 'streak_reminder' }
)
}

32
server/src/types/index.ts Normal file
View File

@ -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
}
}

16
server/tsconfig.json Normal file
View File

@ -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"]
}