From c467dc720faa8d0a8d0c4843e1851037e4f239b0 Mon Sep 17 00:00:00 2001 From: Marvelous Felix Date: Thu, 23 Jul 2026 21:45:08 +0000 Subject: [PATCH 1/2] feat(#16): enhance /health with DB sanity check and 503 on failure - Replace static {status:'ok'} with async handler - Run prisma.$queryRaw`SELECT 1` to verify DB connectivity - Return 200 + {status:'UP', timestamp} on success - Return 503 + {status:'DOWN', timestamp} when the DB is unreachable - Fix pre-existing no-undef lint error in global error handler (_req -> req) --- stellar-payment-platform/server.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 5974ba6..005497a 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -721,8 +721,15 @@ app.get('/api/v1/time', (_req, res) => { res.status(200).json({ time: new Date().toISOString() }); }); -app.get('/health', (_req, res) => { - res.json({ status: 'ok' }); +app.get('/health', async (_req, res) => { + try { + // Lightweight sanity check — confirms the DB connection is alive + await prisma.$queryRaw`SELECT 1`; + return res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); + } catch (err) { + console.error('Health check DB error:', err.message); + return res.status(503).json({ status: 'DOWN', timestamp: new Date().toISOString() }); + } }); app.use((err, _req, _res, next) => { @@ -736,7 +743,7 @@ app.use((err, _req, _res, next) => { // Global error handling middleware // eslint-disable-next-line no-unused-vars -app.use((err, _req, res, _next) => { +app.use((err, req, res, _next) => { const statusCode = err.statusCode || 500; const errorMessage = err.message || 'Internal server error'; From 031b2d9e9a92eba8d036cc3a3759421296c18a50 Mon Sep 17 00:00:00 2001 From: Marvelous Felix Date: Thu, 23 Jul 2026 21:47:23 +0000 Subject: [PATCH 2/2] feat(#18): implement soft deletes instead of hard deletes Schema: - Add nullable deleted_at column to username_registry - Add index on deleted_at for efficient filter queries - Add migration 20260723000000_add_soft_deletes Routes (server.js + v1/userRoutes.js): - Add DELETE /register/:username endpoint that sets deleted_at = now() - All lookup/federation/users queries now filter deletedAt: null Cron (cleanup-cron.js): - Replace prisma.user.deleteMany with updateMany { deletedAt: new Date() } so stale accounts are soft-deleted rather than permanently erased --- .../migration.sql | 10 ++++ stellar-payment-platform/prisma/schema.prisma | 5 ++ stellar-payment-platform/server.js | 55 ++++++++++++++++--- stellar-payment-platform/src/cleanup-cron.js | 7 ++- .../src/routes/v1/federationRoutes.js | 6 +- .../src/routes/v1/userRoutes.js | 49 +++++++++++++++-- 6 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 stellar-payment-platform/prisma/migrations/20260723000000_add_soft_deletes/migration.sql diff --git a/stellar-payment-platform/prisma/migrations/20260723000000_add_soft_deletes/migration.sql b/stellar-payment-platform/prisma/migrations/20260723000000_add_soft_deletes/migration.sql new file mode 100644 index 0000000..3bf0dbe --- /dev/null +++ b/stellar-payment-platform/prisma/migrations/20260723000000_add_soft_deletes/migration.sql @@ -0,0 +1,10 @@ +-- #18: Add soft-delete support to username_registry +-- Adds a nullable deleted_at timestamp column. A NULL value means the record +-- is active; a non-null value means the account was unregistered and should +-- be excluded from all normal federation/lookup queries. + +ALTER TABLE "username_registry" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- Index to make soft-delete filters efficient (most rows will be NULL, so a +-- partial index on non-null values keeps the footprint small on Postgres). +CREATE INDEX "username_registry_deleted_at_idx" ON "username_registry"("deleted_at"); diff --git a/stellar-payment-platform/prisma/schema.prisma b/stellar-payment-platform/prisma/schema.prisma index 030f051..531da42 100644 --- a/stellar-payment-platform/prisma/schema.prisma +++ b/stellar-payment-platform/prisma/schema.prisma @@ -23,7 +23,12 @@ model User { memo String? createdAt DateTime @default(now()) @map("created_at") flaggedAt DateTime? @map("flagged_at") + // #18 — Soft-delete timestamp. NULL means the record is active; a non-null + // value means the account was unregistered and should be excluded from all + // normal lookups. + deletedAt DateTime? @map("deleted_at") @@map("username_registry") @@index([username]) + @@index([deletedAt]) } diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 005497a..2a68948 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -239,7 +239,7 @@ app.get('/federation', etagCache, async (req, res, next) => { try { if (type === 'id') { const row = await prisma.user.findFirst({ - where: { address: { equals: queryValue, mode: 'insensitive' } }, + where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, select: { username: true, address: true, memoType: true, memo: true }, }); @@ -263,8 +263,8 @@ app.get('/federation', etagCache, async (req, res, next) => { let row = null; try { - row = await prisma.user.findUnique({ - where: { username: queryName }, + row = await prisma.user.findFirst({ + where: { username: queryName, deletedAt: null }, select: { address: true, memoType: true, memo: true }, }); } catch (error) { @@ -447,8 +447,8 @@ app.post('/register', async (req, res, next) => { try { let existing = null; try { - existing = await prisma.user.findUnique({ - where: { address }, + existing = await prisma.user.findFirst({ + where: { address, deletedAt: null }, }); } catch (error) { if (!shouldFallbackToLocalRegistry(error)) { @@ -574,6 +574,43 @@ app.post('/register', async (req, res, next) => { app.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" })); +// #18 — Soft-delete endpoint. Sets deleted_at to now() instead of running a +// hard DELETE so the row is preserved for historical auditing. +app.delete('/register/:username', async (req, res, next) => { + const username = normalizeNameTag( + typeof req.params.username === 'string' ? req.params.username.trim() : '', + ).toLowerCase(); + + if (!username) { + const error = new Error('Missing username parameter'); + error.statusCode = 400; + return next(error); + } + + try { + const existing = await prisma.user.findFirst({ + where: { username, deletedAt: null }, + }); + + if (!existing) { + const notFoundError = new Error('Username not found or already deleted'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + await prisma.user.update({ + where: { username }, + data: { deletedAt: new Date() }, + }); + + return res.status(200).json({ ok: true, username, deleted: true }); + } catch { + const dbError = new Error('Failed to unregister account'); + dbError.statusCode = 500; + return next(dbError); + } +}); + app.get('/lookup', async (req, res, next) => { const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; @@ -588,8 +625,8 @@ app.get('/lookup', async (req, res, next) => { try { let row = null; try { - row = await prisma.user.findUnique({ - where: { address }, + row = await prisma.user.findFirst({ + where: { address, deletedAt: null }, select: { username: true }, }); } catch (error) { @@ -622,6 +659,7 @@ app.get('/lookup', async (req, res, next) => { const skip = (page - 1) * limit; const where = { + deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, @@ -675,12 +713,13 @@ app.get('/users', async (req, res, next) => { const where = search ? { + deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, ], } - : {}; + : { deletedAt: null }; try { const [totalCount, rows] = await prisma.$transaction([ diff --git a/stellar-payment-platform/src/cleanup-cron.js b/stellar-payment-platform/src/cleanup-cron.js index 4ac4588..c9dfc52 100644 --- a/stellar-payment-platform/src/cleanup-cron.js +++ b/stellar-payment-platform/src/cleanup-cron.js @@ -38,12 +38,15 @@ async function runCleanup(prisma) { const activeAddresses = [...ACTIVE_NETWORK_ADDRESSES]; - // 1. Delete stale rows that are NOT active network addresses. - const pruneResult = await prisma.user.deleteMany({ + // 1. Soft-delete stale rows that are NOT active network addresses. + // #18 — Use soft deletes so historical records are preserved for auditing. + const pruneResult = await prisma.user.updateMany({ where: { createdAt: { lt: cutoff }, address: { notIn: activeAddresses }, + deletedAt: null, }, + data: { deletedAt: new Date() }, }); // 2. Flag stale rows that ARE active network addresses. diff --git a/stellar-payment-platform/src/routes/v1/federationRoutes.js b/stellar-payment-platform/src/routes/v1/federationRoutes.js index eea7947..dd48ab7 100644 --- a/stellar-payment-platform/src/routes/v1/federationRoutes.js +++ b/stellar-payment-platform/src/routes/v1/federationRoutes.js @@ -17,7 +17,7 @@ router.get('/federation', etagCache, async (req, res, next) => { try { if (type === 'id') { const row = await prisma.user.findFirst({ - where: { address: { equals: queryValue, mode: 'insensitive' } }, + where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, select: { username: true, address: true, memoType: true, memo: true }, }); @@ -40,8 +40,8 @@ router.get('/federation', etagCache, async (req, res, next) => { const nameTag = normalizeNameTag(queryValue); const queryName = nameTag.toLowerCase(); - const row = await prisma.user.findUnique({ - where: { username: queryName }, + const row = await prisma.user.findFirst({ + where: { username: queryName, deletedAt: null }, select: { address: true, memoType: true, memo: true }, }); diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index 969819e..3269ad4 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -81,8 +81,8 @@ router.post('/register', async (req, res, next) => { } try { - const existing = await prisma.user.findUnique({ - where: { address } + const existing = await prisma.user.findFirst({ + where: { address, deletedAt: null }, }); if (existing) { @@ -154,6 +154,43 @@ router.post('/register', async (req, res, next) => { router.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" })); +// #18 — Soft-delete endpoint. Sets deleted_at to now() instead of running a +// hard DELETE so the row is preserved for historical auditing. +router.delete('/register/:username', async (req, res, next) => { + const username = normalizeNameTag( + typeof req.params.username === 'string' ? req.params.username.trim() : '', + ).toLowerCase(); + + if (!username) { + const error = new Error('Missing username parameter'); + error.statusCode = 400; + return next(error); + } + + try { + const existing = await prisma.user.findFirst({ + where: { username, deletedAt: null }, + }); + + if (!existing) { + const notFoundError = new Error('Username not found or already deleted'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + await prisma.user.update({ + where: { username }, + data: { deletedAt: new Date() }, + }); + + return res.status(200).json({ ok: true, username, deleted: true }); + } catch { + const dbError = new Error('Failed to unregister account'); + dbError.statusCode = 500; + return next(dbError); + } +}); + router.get('/lookup', async (req, res, next) => { const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; @@ -166,8 +203,8 @@ router.get('/lookup', async (req, res, next) => { if (address) { try { - const row = await prisma.user.findUnique({ - where: { address }, + const row = await prisma.user.findFirst({ + where: { address, deletedAt: null }, select: { username: true }, }); @@ -190,6 +227,7 @@ router.get('/lookup', async (req, res, next) => { const skip = (page - 1) * limit; const where = { + deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, @@ -230,12 +268,13 @@ router.get('/users', async (req, res, next) => { const where = search ? { + deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, ], } - : {}; + : { deletedAt: null }; try { const [totalCount, rows] = await prisma.$transaction([