diff --git a/.gitignore b/.gitignore index 8668864..fc44627 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,8 @@ dist .pnp.* .vscode -.idea \ No newline at end of file +.idea +# Ignore env backups and local config +.env +.env.* +index.backup.yaml diff --git a/index.yaml b/index.yaml index 1444261..c13e211 100644 --- a/index.yaml +++ b/index.yaml @@ -197,6 +197,56 @@ paths: description: Upload failed due to size/type restriction '429': description: Too many uploads from this IP (rate limit exceeded) + + /consents: + post: + tags: [Consent] + summary: Save a user consent + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [user_id, consent_type] + properties: + user_id: { type: string, format: uuid, example: "93aadd91-aada-4d4c-b064-b2da0a7dce66" } + user_email: { type: string, format: email, example: "user@example.com" } + consent_type: { type: string, example: "privacy_policy" } + granted: { type: boolean, default: true } + metadata: { type: object, additionalProperties: true } + responses: + '201': { description: Consent saved } + '400': { description: Bad request } + '500': { description: Internal server error } + + /consents/{user_id}: + get: + tags: [Consent] + summary: List consents for a user + parameters: + - in: path + name: user_id + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: Consents returned } + '500': { description: Internal server error } + + /consents/{uuid}/revoke: + patch: + tags: [Consent] + summary: Revoke a consent by primary key + parameters: + - in: path + name: uuid + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: Consent revoked } + '404': { description: Consent not found } + '500': { description: Internal server error } + /appointments: post: summary: Save appointment data @@ -3025,6 +3075,7 @@ components: format: float description: Model confidence score for diabetes prediction. example: 0.798 +<<<<<<< HEAD BarcodeAllergenDetection: type: object @@ -3049,3 +3100,19 @@ components: items: type: string +======= + components: + schemas: + ConsentInput: + type: object + required: [user_id, consent_type, granted] + properties: + user_id: { type: string, format: uuid, example: '93aadd91-aada-4d4c-b064-b2da0a7dcee6' } + user_email: { type: string, format: email, example: 'user@example.com' } + consent_type: { type: string, example: 'privacy_policy' } + granted: { type: boolean, default: true } + metadata: + type: object + additionalProperties: true + +>>>>>>> 38b1413 (feat(consent): add POST /consents, GET /consents/{user_id}, PATCH /consents/{id}/revoke; wire route; update OpenAPI docs) diff --git a/new_utils/supabaseAdmin.js b/new_utils/supabaseAdmin.js new file mode 100644 index 0000000..dadf339 --- /dev/null +++ b/new_utils/supabaseAdmin.js @@ -0,0 +1,12 @@ +// new_utils/supabaseAdmin.js +const { createClient } = require('@supabase/supabase-js'); + +const SUPABASE_URL = process.env.SUPABASE_URL; +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + +// Create a server-side (non-persisted) admin client +const supabaseAdmin = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false } +}); + +module.exports = supabaseAdmin; // <-- default export diff --git a/package-lock.json b/package-lock.json index 2afa4f2..943ec7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.9.2", "node-fetch": "^3.3.2", + "nodemailer": "^7.0.6", "nutrihelp-api": "file:", "sinon": "^18.0.0", "swagger-ui-express": "^5.0.0", @@ -5764,6 +5765,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/package.json b/package.json index 79bc168..d0340d3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.9.2", "node-fetch": "^3.3.2", + "nodemailer": "^7.0.6", "nutrihelp-api": "file:", "sinon": "^18.0.0", "swagger-ui-express": "^5.0.0", diff --git a/routes/consent.js b/routes/consent.js new file mode 100644 index 0000000..ceb9835 --- /dev/null +++ b/routes/consent.js @@ -0,0 +1,127 @@ +// routes/consent.js +const express = require('express'); +const router = express.Router(); +const supabase = require('../new_utils/supabaseAdmin'); // SERVICE_ROLE client + +// ─────────────────────────────────────────────────────────────── +// CREATE / UPSERT +// POST /api/consents +// Body: { user_id, user_email?, consent_type, granted:boolean, metadata? } +// Unique key: (user_id, consent_type) +// ─────────────────────────────────────────────────────────────── +router.post('/consents', async (req, res) => { + try { + const { user_id, user_email, consent_type, granted, metadata } = req.body || {}; + if (!user_id || !consent_type || typeof granted !== 'boolean') { + return res + .status(400) + .json({ error: 'user_id, consent_type and granted (boolean) are required' }); + } + + const now = new Date().toISOString(); + const row = { + user_id, + user_email: user_email || null, + consent_type, + granted: !!granted, + revoked_at: granted ? null : now, + metadata: metadata || null, + }; + + const { data, error } = await supabase + .from('consents') + .upsert(row, { onConflict: 'user_id,consent_type' }) + .select('*') + .single(); + + if (error) throw error; + return res.status(201).json({ message: 'Consent saved', row: data }); + } catch (e) { + console.error('[consents POST] error:', e); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ─────────────────────────────────────────────────────────────── +// LIST +// GET /api/consents/:user_id +// ─────────────────────────────────────────────────────────────── +router.get('/consents/:user_id', async (req, res) => { + try { + const { user_id } = req.params; + const { data, error } = await supabase + .from('consents') + .select('*') + .eq('user_id', user_id) + .order('created_at', { ascending: false }); + + if (error) throw error; + return res.status(200).json({ consents: data }); + } catch (e) { + console.error('[consents GET] error:', e); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ─────────────────────────────────────────────────────────────── +// REVOKE by consent row PK +// PATCH /api/consents/:id/revoke +// Also supports /api/consents/:uuid/revoke (Swagger alias) +// ─────────────────────────────────────────────────────────────── +async function revokeByIdHandler(req, res) { + try { + // Accept both param names + const id = req.params.id || req.params.uuid; + console.log('[revokeById] params =', req.params, 'resolved id =', id); + if (!id) return res.status(400).json({ error: 'Missing consent id' }); + + const now = new Date().toISOString(); + const { data, error } = await supabase + .from('consents') + .update({ granted: false, revoked_at: now }) + .eq('id', id) + .select('*'); + + // data can be [] if nothing matched + if (error) return res.status(500).json({ error: error.message }); + if (!data || data.length === 0) return res.status(404).json({ error: 'Consent not found' }); + + // If somehow multiple rows matched (shouldn’t happen for PK), return the first + return res.status(200).json({ message: 'Consent revoked', row: data[0] }); + } catch (e) { + console.error('[revokeById] error:', e); + return res.status(500).json({ error: 'Internal server error' }); + } +} +router.patch('/consents/:id/revoke', revokeByIdHandler); +router.patch('/consents/:uuid/revoke', revokeByIdHandler); // Swagger still using {uuid} + +// ─────────────────────────────────────────────────────────────── +// REVOKE by (user_id, consent_type) unique pair +// PATCH /api/consents/by-user/:user_id/:consent_type/revoke +// ─────────────────────────────────────────────────────────────── +router.patch('/consents/by-user/:user_id/:consent_type/revoke', async (req, res) => { + try { + const { user_id, consent_type } = req.params; + console.log('[revokeByUser] params =', req.params); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from('consents') + .update({ granted: false, revoked_at: now }) + .eq('user_id', user_id) + .eq('consent_type', consent_type) + .select('*'); + + if (error) return res.status(500).json({ error: error.message }); + if (!data || data.length === 0) return res.status(404).json({ error: 'Consent not found' }); + + // unique constraint should make this 1 row; handle array defensively + return res.status(200).json({ message: 'Consent revoked', row: data[0] }); + } catch (e) { + console.error('[revokeByUser] error:', e); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 86399c0..d4c03ca 100644 --- a/server.js +++ b/server.js @@ -12,8 +12,12 @@ const rateLimit = require('express-rate-limit'); const uploadRoutes = require('./routes/uploadRoutes'); const fs = require("fs"); const path = require("path"); +<<<<<<< HEAD const systemRoutes = require('./routes/systemRoutes'); const loginDashboard = require('./routes/loginDashboard.js'); +======= +const consentRoutes = require('./routes/consent'); +>>>>>>> 38b1413 (feat(consent): add POST /consents, GET /consents/{user_id}, PATCH /consents/{id}/revoke; wire route; update OpenAPI docs) // Ensure uploads directory exists const uploadsDir = path.join(__dirname, 'uploads'); @@ -126,7 +130,20 @@ app.use((err, req, res, next) => { res.status(500).json({ error: "Internal server error" }); }); +<<<<<<< HEAD // Start +======= +// Global error handler +app.use((err, req, res, next) => { + console.error("Unhandled error:", err); + res.status(500).json({ error: "Internal server error" }); +}); + +// Consents use +app.use('/api', consentRoutes); + +// Start server +>>>>>>> 38b1413 (feat(consent): add POST /consents, GET /consents/{user_id}, PATCH /consents/{id}/revoke; wire route; update OpenAPI docs) app.listen(port, async () => { console.log('\n🎉 NutriHelp API launched successfully!'); console.log('='.repeat(50));