Files
Michelle ZanandCursor 0ca2e87bf2 Ship signup sheet, subscribe reminders, cover crop, and theme updates.
Wire real WeChat app config, prefer activity cover over posters, and add admin cover cropping for the home banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 13:06:43 +08:00

239 lines
7.9 KiB
JavaScript

const express = require('express')
const db = require('../lib/db')
const wechat = require('../lib/wechat')
const { uploader } = require('../lib/upload')
const { userAuth, requireUserAdmin } = require('../lib/auth')
const { HttpError, uid, token, ok, fail, wrap, str, has, PHONE_RE } = require('../lib/http')
const {
publicActivity,
currentActivity,
pastActivities,
nextPastTheme,
setPinned,
publicProfile,
DEFAULT_BRAND
} = require('../lib/serialize')
const router = express.Router()
router.post(
'/login',
wrap(async (req, res) => {
const body = req.body || {}
const code = str(body.code)
if (!code) throw new HttpError(400, '缺少 code')
const session = await wechat.code2session(code, str(body.deviceId))
const profile = db.update((data) => {
let user = data.users.find((item) => item.openid === session.openid)
if (!user) {
user = {
openid: session.openid,
nickName: '',
avatarUrl: '',
name: '',
phone: '',
company: '',
position: '',
createdAt: new Date().toISOString()
}
data.users.push(user)
}
user.token = token()
return Object.assign({ token: user.token, demo: session.demo }, publicProfile(user, data))
})
ok(res, profile)
})
)
router.get('/user/profile', userAuth, (req, res) => {
ok(res, publicProfile(req.user))
})
const PROFILE_LIMITS = { nickName: 32, name: 20, phone: 11, company: 40, position: 40 }
router.post('/user/profile', userAuth, (req, res) => {
const body = req.body || {}
if (has(body, 'phone') && str(body.phone) && !PHONE_RE.test(str(body.phone))) {
return fail(res, 400, '请填写正确的手机号')
}
const profile = db.update((data) => {
const user = req.user
Object.keys(PROFILE_LIMITS).forEach((key) => {
if (has(body, key)) user[key] = str(body[key], PROFILE_LIMITS[key])
})
// Only server-hosted avatars are accepted; temp paths like http://tmp/... are useless to others.
const avatarUrl = str(body.avatarUrl)
if (avatarUrl && avatarUrl.startsWith('/static/uploads/')) user.avatarUrl = avatarUrl
return publicProfile(user, data)
})
ok(res, profile)
})
router.post('/user/avatar', userAuth, uploader({ imagesOnly: true }))
router.get('/activity/current', (req, res) => {
const data = db.read()
const activity = currentActivity(data)
ok(res, activity ? publicActivity(activity, data.posters) : null)
})
router.get('/activity/past', (req, res) => {
const data = db.read()
ok(res, pastActivities(data).map((item) => publicActivity(item, data.posters)))
})
router.get('/activity/:id', (req, res) => {
const data = db.read()
const activity = data.activities.find((item) => item.id === req.params.id && item.status !== 'draft')
if (!activity) return fail(res, 404, '活动不存在')
ok(res, publicActivity(activity, data.posters))
})
router.post('/activity/past', userAuth, requireUserAdmin, (req, res) => {
const body = req.body || {}
const title = str(body.title, 40)
const startAt = str(body.startAt, 32)
if (!title || !startAt) return fail(res, 400, '请填写名称和时间')
const activity = db.update((data) => {
const now = new Date().toISOString()
const next = {
id: uid('p'),
title,
brandName: DEFAULT_BRAND,
brandDesc: '',
cover: str(body.cover),
video: str(body.video),
startAt,
address: '',
latitude: null,
longitude: null,
status: 'past',
isCurrent: false,
pinned: false,
pinnedAt: '',
theme: nextPastTheme(data),
createdAt: now,
updatedAt: now
}
if (body.pinned) setPinned(next, true)
data.activities.push(next)
return next
})
ok(res, publicActivity(activity))
})
function findPast(data, id) {
return data.activities.find((item) => item.id === id && item.status === 'past')
}
router.post('/activity/past/:id/pin', userAuth, requireUserAdmin, (req, res) => {
const updated = db.update((data) => {
const activity = findPast(data, req.params.id)
return activity ? setPinned(activity, !!(req.body && req.body.pinned)) : null
})
if (!updated) return fail(res, 404, '往期活动不存在')
ok(res, publicActivity(updated))
})
router.delete('/activity/past/:id', userAuth, requireUserAdmin, (req, res) => {
const removed = db.update((data) => {
const activity = findPast(data, req.params.id)
if (!activity) return null
data.activities = data.activities.filter((item) => item.id !== activity.id)
data.posters = data.posters.filter((item) => item.activityId !== activity.id)
return activity.id
})
if (!removed) return fail(res, 404, '往期活动不存在')
ok(res, { id: removed })
})
router.get('/subscribe/config', (req, res) => {
const settings = db.read().settings || {}
const tmplId = str(settings.subscribeTmplId)
ok(res, {
enabled: !!tmplId,
tmplIds: tmplId ? [tmplId] : [],
notice: '提交即视为同意接收活动提醒'
})
})
router.post('/signup', userAuth, (req, res) => {
const body = req.body || {}
const activityId = str(body.activityId)
const name = str(body.name, 20)
const phone = str(body.phone)
let sourceSharer = str(body.sourceSharer, 64)
// accept / reject / ban / filter / none — matches wx.requestSubscribeMessage result values
const remindStatus = str(body.remindStatus || 'none', 16) || 'none'
if (!activityId || !name || !phone) return fail(res, 400, '请填写姓名和手机号')
if (!PHONE_RE.test(phone)) return fail(res, 400, '请填写正确的手机号')
if (sourceSharer === req.user.openid) sourceSharer = ''
const result = db.update((data) => {
const activity = data.activities.find((item) => item.id === activityId)
if (!activity || activity.status === 'draft') return { error: '活动不存在' }
if (activity.status === 'past') return { error: '这场活动已结束' }
const exists = data.signups.some((item) => item.activityId === activityId && item.openid === req.user.openid)
if (exists) return { error: '你已经报名过了' }
const signup = {
id: uid('s'),
activityId,
activityTitle: activity.title,
openid: req.user.openid,
name,
phone,
company: str(body.company, 40),
position: str(body.position, 40),
sourceSharer,
remindStatus,
remindAccepted: remindStatus === 'accept',
createdAt: new Date().toISOString(),
checkedIn: false,
checkedInAt: ''
}
data.signups.push(signup)
return { signup }
})
if (result.error) return fail(res, 400, result.error)
ok(res, result.signup)
})
router.get('/signup/mine', userAuth, (req, res) => {
const signups = db
.read()
.signups.filter((item) => item.openid === req.user.openid)
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
ok(res, { signups, checkins: signups.filter((item) => item.checkedIn) })
})
router.post('/checkin/scan', userAuth, (req, res) => {
const body = req.body || {}
const result = db.update((data) => {
let activityId = ''
if (body.simulate) {
const activity = currentActivity(data)
if (!activity) return { error: '当前没有进行中的活动' }
activityId = activity.id
} else {
const code = data.checkinCodes.find((item) => item.token === str(body.token))
if (!code) return { error: '签到码无效' }
if (code.expiresAt && Date.parse(code.expiresAt) < Date.now()) return { error: '签到码已过期' }
activityId = code.activityId
}
const signup = data.signups.find((item) => item.activityId === activityId && item.openid === req.user.openid)
if (!signup) return { error: '请先报名再签到' }
if (!signup.checkedIn) {
signup.checkedIn = true
signup.checkedInAt = new Date().toISOString()
}
return { signup }
})
if (result.error) return fail(res, 400, result.error)
ok(res, result.signup)
})
router.post('/upload', userAuth, requireUserAdmin, uploader())
module.exports = router