chore(server): add Node.js backend with auth, questions, answers + update gitignore
This commit is contained in:
parent
1a33d4f2b9
commit
bee617c493
|
|
@ -37,3 +37,4 @@ out/
|
|||
|
||||
# App module build
|
||||
app/build/
|
||||
SecurityReport.md
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
firebase-service-account.json
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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' }
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue