Add membership renewals and door unlock
This commit is contained in:
242
server/server.js
242
server/server.js
@@ -61,6 +61,7 @@ const files = {
|
||||
siteContent: path.join(dataDir, 'site-content.json'),
|
||||
adminRequests: path.join(dataDir, 'admin-requests.json'),
|
||||
notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'),
|
||||
membershipNotifications: path.join(dataDir, 'membership-notifications.json'),
|
||||
}
|
||||
|
||||
const state = {
|
||||
@@ -71,6 +72,7 @@ const state = {
|
||||
siteContent: null,
|
||||
adminRequests: [],
|
||||
notificationSubscriptions: [],
|
||||
membershipNotifications: [],
|
||||
}
|
||||
let bitcoinPriceCache = null
|
||||
const adminEventClients = new Set()
|
||||
@@ -140,6 +142,7 @@ const loadStore = async () => {
|
||||
state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent))
|
||||
state.adminRequests = await loadJson(files.adminRequests)
|
||||
state.notificationSubscriptions = await loadJson(files.notificationSubscriptions)
|
||||
state.membershipNotifications = await loadJson(files.membershipNotifications)
|
||||
}
|
||||
|
||||
const saveMemberships = () => saveJson(files.memberships, state.memberships)
|
||||
@@ -149,6 +152,7 @@ const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs)
|
||||
const saveSiteContent = () => saveJson(files.siteContent, state.siteContent)
|
||||
const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests)
|
||||
const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions)
|
||||
const saveMembershipNotifications = () => saveJson(files.membershipNotifications, state.membershipNotifications)
|
||||
|
||||
const readBody = async (req) => {
|
||||
const raw = await readRawBody(req)
|
||||
@@ -289,8 +293,34 @@ const decryptMember = (member) => decryptMembership(member)
|
||||
const encryptedMembers = () => state.memberships
|
||||
const members = () => state.memberships.map(decryptMember)
|
||||
const findMember = (predicate) => members().find(predicate)
|
||||
const activePaymentFor = (membershipId) =>
|
||||
state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')
|
||||
const membershipPeriodOptions = [1, 2, 3, 6, 12]
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
const normalizePaymentMonths = (value) => {
|
||||
const months = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(months)) return 1
|
||||
return Math.max(1, Math.min(24, months))
|
||||
}
|
||||
|
||||
const addMembershipMonths = (date, months) => {
|
||||
const start = new Date(date)
|
||||
const day = start.getDate()
|
||||
const result = new Date(start)
|
||||
result.setDate(1)
|
||||
result.setMonth(result.getMonth() + normalizePaymentMonths(months))
|
||||
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()
|
||||
result.setDate(Math.min(day, lastDay))
|
||||
return result
|
||||
}
|
||||
|
||||
const activePaymentFor = (membershipId) => {
|
||||
const now = new Date()
|
||||
return state.payments.find((payment) =>
|
||||
payment.membershipId === membershipId &&
|
||||
payment.status === 'paid' &&
|
||||
(!payment.paidThrough || new Date(payment.paidThrough) > now)
|
||||
)
|
||||
}
|
||||
const activeCardFor = (membershipId) =>
|
||||
state.cards.find((card) => card.membershipId === membershipId && card.status === 'active')
|
||||
|
||||
@@ -303,6 +333,16 @@ const memberAccessStatus = (member) => {
|
||||
return member.status || 'requested'
|
||||
}
|
||||
|
||||
const updateMemberRecord = async (membershipId, updater) => {
|
||||
const index = members().findIndex((member) => member.membershipId === membershipId)
|
||||
if (index < 0) return null
|
||||
const current = decryptMember(state.memberships[index])
|
||||
const updated = updater({ ...current }) || current
|
||||
state.memberships[index] = encryptMembership(updated)
|
||||
await saveMemberships()
|
||||
return updated
|
||||
}
|
||||
|
||||
const validateMember = (data) => {
|
||||
const signerNpubs = Array.isArray(data.signerNpubs)
|
||||
? data.signerNpubs.map((item) => cleanText(item, 80)).filter(Boolean)
|
||||
@@ -339,7 +379,7 @@ const validateMember = (data) => {
|
||||
|
||||
if (!member.expiresAt) {
|
||||
const expiresAt = new Date(member.createdAt)
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||
member.expiresAt = expiresAt.toISOString()
|
||||
}
|
||||
|
||||
@@ -438,6 +478,111 @@ const sendPushNotification = async ({ title, message, url = '/edit', icon = '/im
|
||||
return { success: true, sent, failed }
|
||||
}
|
||||
|
||||
const sendPushNotificationToMember = async (member, payload) => {
|
||||
const targets = state.notificationSubscriptions.filter((subscription) =>
|
||||
subscription.membershipId === member.membershipId ||
|
||||
subscription.npub === member.npub ||
|
||||
member.signerNpubs?.includes(subscription.npub)
|
||||
)
|
||||
if (!targets.length) return { success: false, reason: 'no_member_subscriptions', sent: 0, failed: 0 }
|
||||
if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) {
|
||||
return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 }
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
title: cleanText(payload.title, 80) || 'L484',
|
||||
message: cleanText(payload.message, 220) || 'New L484 update.',
|
||||
icon: cleanText(payload.icon, 240) || '/images/small-logo.svg',
|
||||
tag: cleanText(payload.tag, 80) || 'l484-update',
|
||||
data: { url: cleanText(payload.url, 240) || '/' },
|
||||
})
|
||||
const targetEndpoints = new Set(targets.map((subscription) => subscription.endpoint))
|
||||
const results = await Promise.allSettled(targets.map((subscription) => webpush.sendNotification(subscription, body)))
|
||||
const invalidEndpoints = new Set()
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
sent += 1
|
||||
return
|
||||
}
|
||||
failed += 1
|
||||
if ([404, 410].includes(result.reason?.statusCode)) invalidEndpoints.add(targets[index].endpoint)
|
||||
})
|
||||
if (invalidEndpoints.size) {
|
||||
state.notificationSubscriptions = state.notificationSubscriptions.filter((subscription) =>
|
||||
!targetEndpoints.has(subscription.endpoint) || !invalidEndpoints.has(subscription.endpoint)
|
||||
)
|
||||
await saveNotificationSubscriptions()
|
||||
}
|
||||
return { success: true, sent, failed }
|
||||
}
|
||||
|
||||
const applyPaidMembershipPeriod = async (payment) => {
|
||||
const member = findMember((item) => item.membershipId === payment.membershipId)
|
||||
if (!member) return null
|
||||
const now = new Date()
|
||||
const existingExpiry = new Date(member.expiresAt)
|
||||
const coversFrom = Number.isNaN(existingExpiry.getTime()) || existingExpiry < now ? now : existingExpiry
|
||||
const months = normalizePaymentMonths(payment.months)
|
||||
const paidThrough = addMembershipMonths(coversFrom, months)
|
||||
payment.months = months
|
||||
payment.periodLabel = months === 1 ? '1 month' : `${months} months`
|
||||
payment.amountUsd = membershipMonthlyUsd * months
|
||||
payment.coversFrom = coversFrom.toISOString()
|
||||
payment.paidThrough = paidThrough.toISOString()
|
||||
const updated = await updateMemberRecord(payment.membershipId, (current) => ({
|
||||
...current,
|
||||
expiresAt: paidThrough.toISOString(),
|
||||
status: activeCardFor(payment.membershipId) ? 'active' : 'pending_payment',
|
||||
}))
|
||||
await savePayments()
|
||||
return updated
|
||||
}
|
||||
|
||||
const reconcileMembershipCycles = async ({ notify = false } = {}) => {
|
||||
const now = new Date()
|
||||
const allMembers = members()
|
||||
let changed = false
|
||||
for (const member of allMembers) {
|
||||
const expiry = new Date(member.expiresAt)
|
||||
if (Number.isNaN(expiry.getTime())) continue
|
||||
if (['active', 'pending_card', 'pending_payment'].includes(member.status) && expiry <= now) {
|
||||
await updateMemberRecord(member.membershipId, (current) => ({ ...current, status: 'suspended' }))
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (!notify || !['active', 'pending_card', 'pending_payment'].includes(member.status)) continue
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - now.getTime()) / dayMs)
|
||||
if (daysUntilExpiry < 1 || daysUntilExpiry > 3) continue
|
||||
const dayKey = now.toISOString().slice(0, 10)
|
||||
const alreadySent = state.membershipNotifications.some((entry) =>
|
||||
entry.membershipId === member.membershipId &&
|
||||
entry.type === 'expiry_reminder' &&
|
||||
entry.dayKey === dayKey
|
||||
)
|
||||
if (alreadySent) continue
|
||||
const result = await sendPushNotificationToMember(member, {
|
||||
title: 'Membership renewal due',
|
||||
message: `Your L484 membership expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? '' : 's'}.`,
|
||||
url: '/',
|
||||
tag: `l484-renewal-${member.membershipId}-${dayKey}`,
|
||||
})
|
||||
state.membershipNotifications.unshift({
|
||||
id: createId('membership-notice'),
|
||||
membershipId: member.membershipId,
|
||||
type: 'expiry_reminder',
|
||||
dayKey,
|
||||
expiresAt: expiry.toISOString(),
|
||||
sent: result.sent || 0,
|
||||
failed: result.failed || 0,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
state.membershipNotifications = state.membershipNotifications.slice(0, 2000)
|
||||
await saveMembershipNotifications()
|
||||
}
|
||||
return { changed }
|
||||
}
|
||||
|
||||
const broadcastAdminEvent = (event, payload = {}) => {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
|
||||
for (const client of adminEventClients) {
|
||||
@@ -543,12 +688,7 @@ const upsertMember = async (member) => {
|
||||
}
|
||||
|
||||
const updateMemberStatus = async (membershipId, status) => {
|
||||
const index = members().findIndex((member) => member.membershipId === membershipId)
|
||||
if (index < 0) return null
|
||||
const member = { ...decryptMember(state.memberships[index]), status }
|
||||
state.memberships[index] = encryptMembership(member)
|
||||
await saveMemberships()
|
||||
return member
|
||||
return updateMemberRecord(membershipId, (member) => ({ ...member, status }))
|
||||
}
|
||||
|
||||
const serializeAdmin = (pubkey = '') => {
|
||||
@@ -560,6 +700,8 @@ const serializeAdmin = (pubkey = '') => {
|
||||
},
|
||||
memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })),
|
||||
payments: state.payments,
|
||||
membershipMonthlyUsd,
|
||||
membershipPeriodOptions,
|
||||
cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card),
|
||||
accessLogs: state.accessLogs.slice(0, 200),
|
||||
}
|
||||
@@ -688,6 +830,8 @@ const seedDevelopmentStore = async () => {
|
||||
id: `payment-dev-paid-${member.membershipId.slice(-6).toLowerCase()}`,
|
||||
membershipId: member.membershipId,
|
||||
provider: index % 2 ? 'btcpay' : 'cash',
|
||||
months: 1,
|
||||
periodLabel: '1 month',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
amountSats: index % 2 ? 420000 : 0,
|
||||
status: 'paid',
|
||||
@@ -695,11 +839,15 @@ const seedDevelopmentStore = async () => {
|
||||
checkoutLink: index % 2 ? `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-paid-${index + 1}` : '',
|
||||
createdAt: member.createdAt,
|
||||
paidAt: member.createdAt,
|
||||
coversFrom: member.createdAt,
|
||||
paidThrough: member.expiresAt,
|
||||
})),
|
||||
...pendingMembers.map((member, index) => ({
|
||||
id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`,
|
||||
membershipId: member.membershipId,
|
||||
provider: 'btcpay',
|
||||
months: 1,
|
||||
periodLabel: '1 month',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
amountSats: 420000,
|
||||
status: 'pending',
|
||||
@@ -812,8 +960,10 @@ const fetchBtcpayPaymentMethods = async (invoiceId) => {
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
const createBtcpayInvoice = async (member) => {
|
||||
const createBtcpayInvoice = async (member, months = 1) => {
|
||||
if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.')
|
||||
const normalizedMonths = normalizePaymentMonths(months)
|
||||
const amountUsd = membershipMonthlyUsd * normalizedMonths
|
||||
const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -821,11 +971,12 @@ const createBtcpayInvoice = async (member) => {
|
||||
Authorization: `token ${btcpayApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: membershipMonthlyUsd,
|
||||
amount: amountUsd,
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
orderId: member.membershipId,
|
||||
itemDesc: 'L484 monthly membership',
|
||||
membershipMonths: normalizedMonths,
|
||||
itemDesc: normalizedMonths === 1 ? 'L484 monthly membership' : `L484 ${normalizedMonths} month membership`,
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -859,15 +1010,16 @@ const verifyBtcpayWebhook = (req, rawBody) => {
|
||||
const markInvoicePaid = async (invoiceId) => {
|
||||
const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId)
|
||||
if (!payment) return null
|
||||
if (payment.status === 'paid') return payment
|
||||
payment.status = 'paid'
|
||||
payment.paidAt = new Date().toISOString()
|
||||
await savePayments()
|
||||
await updateMemberStatus(payment.membershipId, activeCardFor(payment.membershipId) ? 'active' : 'pending_payment')
|
||||
await applyPaidMembershipPeriod(payment)
|
||||
broadcastAdminEvent('payment-paid', {
|
||||
invoiceId,
|
||||
membershipId: payment.membershipId,
|
||||
paymentId: payment.id,
|
||||
paidAt: payment.paidAt,
|
||||
paidThrough: payment.paidThrough,
|
||||
})
|
||||
return payment
|
||||
}
|
||||
@@ -895,6 +1047,8 @@ const paymentStatusPayload = async (payment) => {
|
||||
invoice: {
|
||||
id: payment.btcpayInvoiceId || payment.id,
|
||||
amount: payment.amountUsd,
|
||||
months: normalizePaymentMonths(payment.months),
|
||||
periodLabel: payment.periodLabel || (normalizePaymentMonths(payment.months) === 1 ? '1 month' : `${normalizePaymentMonths(payment.months)} months`),
|
||||
currency: 'USD',
|
||||
status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'),
|
||||
paymentUrl: checkoutLink,
|
||||
@@ -905,6 +1059,7 @@ const paymentStatusPayload = async (payment) => {
|
||||
lightningPaymentLink: lightning?.paymentLink || '',
|
||||
bitcoinPaymentLink: bitcoin?.paymentLink || '',
|
||||
createdAt: payment.createdAt,
|
||||
paidThrough: payment.paidThrough || '',
|
||||
expiresAt: invoice?.expirationTime ? new Date(Number(invoice.expirationTime) * 1000).toISOString() : '',
|
||||
},
|
||||
}
|
||||
@@ -958,6 +1113,8 @@ const handleApi = async (req, res) => {
|
||||
if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
|
||||
return json(res, 400, { error: 'Invalid push subscription.' })
|
||||
}
|
||||
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
|
||||
const existingSubscription = existingIndex >= 0 ? state.notificationSubscriptions[existingIndex] : null
|
||||
const normalized = {
|
||||
endpoint,
|
||||
expirationTime: subscription.expirationTime || null,
|
||||
@@ -965,10 +1122,12 @@ const handleApi = async (req, res) => {
|
||||
p256dh: cleanText(subscription.keys.p256dh, 500),
|
||||
auth: cleanText(subscription.keys.auth, 200),
|
||||
},
|
||||
membershipId: cleanText(body.membershipId || existingSubscription?.membershipId, 32).toUpperCase(),
|
||||
npub: cleanText(body.npub || existingSubscription?.npub, 90),
|
||||
userAgent: cleanText(req.headers['user-agent'], 240),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: existingSubscription?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
|
||||
if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized
|
||||
else state.notificationSubscriptions.unshift(normalized)
|
||||
await saveNotificationSubscriptions()
|
||||
@@ -1040,6 +1199,36 @@ const handleApi = async (req, res) => {
|
||||
return json(res, found ? 200 : 404, found ? { success: true, membership: found } : { error: 'No membership found for that nsec.' })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/member/door/unlock') {
|
||||
if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership access is disabled on this deployment.' })
|
||||
const body = await readBody(req)
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
|
||||
const doorId = cleanText(body.doorId, 80) || 'front-door'
|
||||
const member = findMember((item) => item.membershipId === membershipId && item.nsecHash === nsecHash)
|
||||
const card = activeCardFor(membershipId)
|
||||
const status = memberAccessStatus(member)
|
||||
const allow = Boolean(member && card && status === 'active')
|
||||
const unlock = allow
|
||||
? await triggerDoorUnlock({ doorId, member, card })
|
||||
: { attempted: false, ok: false, reason: member ? `member_${status}` : 'member_not_found' }
|
||||
await recordAccess({
|
||||
membershipId: member?.membershipId || membershipId,
|
||||
cardId: card?.id || '',
|
||||
cardPublicId: card?.cardPublicId || '',
|
||||
doorId,
|
||||
decision: allow && unlock.ok ? 'allow' : 'deny',
|
||||
reason: allow ? unlock.reason : unlock.reason,
|
||||
})
|
||||
return json(res, allow && unlock.ok ? 200 : 403, {
|
||||
success: allow && unlock.ok,
|
||||
allow,
|
||||
reason: allow ? unlock.reason : unlock.reason,
|
||||
unlock,
|
||||
member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null,
|
||||
})
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/admin/state') {
|
||||
if (!requireAdmin(req, res)) return
|
||||
return json(res, 200, serializeAdmin(getAuthPubkey(req)))
|
||||
@@ -1143,11 +1332,14 @@ const handleApi = async (req, res) => {
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const member = findMember((item) => item.membershipId === membershipId)
|
||||
if (!member) return json(res, 404, { error: 'Member not found.' })
|
||||
const months = normalizePaymentMonths(body.months)
|
||||
const payment = {
|
||||
id: createId('payment'),
|
||||
membershipId,
|
||||
provider: cleanText(body.provider, 24) || 'manual',
|
||||
amountUsd: Number(body.amountUsd || membershipMonthlyUsd),
|
||||
months,
|
||||
periodLabel: months === 1 ? '1 month' : `${months} months`,
|
||||
amountUsd: membershipMonthlyUsd * months,
|
||||
amountSats: Number(body.amountSats || 0),
|
||||
status: 'paid',
|
||||
btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120),
|
||||
@@ -1155,8 +1347,7 @@ const handleApi = async (req, res) => {
|
||||
paidAt: new Date().toISOString(),
|
||||
}
|
||||
state.payments.unshift(payment)
|
||||
await savePayments()
|
||||
await updateMemberStatus(membershipId, activeCardFor(membershipId) ? 'active' : 'pending_payment')
|
||||
await applyPaidMembershipPeriod(payment)
|
||||
return json(res, 201, { success: true, payment })
|
||||
}
|
||||
|
||||
@@ -1167,12 +1358,15 @@ const handleApi = async (req, res) => {
|
||||
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
|
||||
const member = findMember((item) => item.membershipId === membershipId)
|
||||
if (!member) return json(res, 404, { error: 'Member not found.' })
|
||||
const invoice = await createBtcpayInvoice(member)
|
||||
const months = normalizePaymentMonths(body.months)
|
||||
const invoice = await createBtcpayInvoice(member, months)
|
||||
const payment = {
|
||||
id: createId('payment'),
|
||||
membershipId,
|
||||
provider: 'btcpay',
|
||||
amountUsd: membershipMonthlyUsd,
|
||||
months,
|
||||
periodLabel: months === 1 ? '1 month' : `${months} months`,
|
||||
amountUsd: membershipMonthlyUsd * months,
|
||||
amountSats: 0,
|
||||
status: 'pending',
|
||||
btcpayInvoiceId: cleanText(invoice.id, 120),
|
||||
@@ -1182,7 +1376,7 @@ const handleApi = async (req, res) => {
|
||||
}
|
||||
state.payments.unshift(payment)
|
||||
await savePayments()
|
||||
await updateMemberStatus(membershipId, 'pending_payment')
|
||||
if (!['active', 'pending_card'].includes(memberAccessStatus(member))) await updateMemberStatus(membershipId, 'pending_payment')
|
||||
return json(res, 201, await paymentStatusPayload(payment))
|
||||
}
|
||||
|
||||
@@ -1275,6 +1469,10 @@ const handleApi = async (req, res) => {
|
||||
|
||||
await loadStore()
|
||||
await seedDevelopmentStore()
|
||||
await reconcileMembershipCycles({ notify: true })
|
||||
setInterval(() => {
|
||||
reconcileMembershipCycles({ notify: true }).catch((error) => console.warn('Membership cycle reconciliation failed:', error.message))
|
||||
}, 60 * 60 * 1000)
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user