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 module build
|
||||||
app/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