Tonne of things
This commit is contained in:
197
api/package-lock.json
generated
197
api/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@hocuspocus/server": "^3.4.4",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -30,10 +31,10 @@
|
||||
"ioredis": "^5.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime-types": "^3.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-ical": "^0.25.5",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.18.0",
|
||||
"proj4": "^2.20.2",
|
||||
"prom-client": "^15.1.3",
|
||||
@@ -42,7 +43,9 @@
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.3.1",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"yjs": "^13.6.29",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,9 +56,10 @@
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"prisma": "^6.3.0",
|
||||
"tsx": "^4.19.2",
|
||||
@@ -1179,6 +1183,31 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@hocuspocus/common": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.4.4.tgz",
|
||||
"integrity": "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.87"
|
||||
}
|
||||
},
|
||||
"node_modules/@hocuspocus/server": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
|
||||
"integrity": "sha512-UV+oaONAejOzeYgUygNcgsc8RdZvSokVvAxluZJIisLACpRO/VsseQ5lWKDRwLd7Fn6+rHWDH3hGuQ1fdX1Ycg==",
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "^3.4.4",
|
||||
"async-lock": "^1.3.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"kleur": "^4.1.4",
|
||||
"lib0": "^0.2.47",
|
||||
"ws": "^8.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
@@ -1943,9 +1972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.22.tgz",
|
||||
"integrity": "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -2007,6 +2036,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -2135,6 +2173,19 @@
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
|
||||
},
|
||||
"node_modules/async-lock": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
|
||||
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="
|
||||
},
|
||||
"node_modules/async-mutex": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -3969,6 +4020,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||
@@ -4065,11 +4125,39 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -4295,14 +4383,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@@ -4311,17 +4391,6 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -4357,21 +4426,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
@@ -4428,9 +4497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -5655,6 +5724,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@@ -5663,6 +5752,26 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y-protocols": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.85"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
@@ -5716,6 +5825,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.29",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
|
||||
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@hocuspocus/server": "^3.4.4",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -38,10 +39,10 @@
|
||||
"ioredis": "^5.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime-types": "^3.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-ical": "^0.25.5",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.18.0",
|
||||
"proj4": "^2.20.2",
|
||||
"prom-client": "^15.1.3",
|
||||
@@ -50,7 +51,9 @@
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.3.1",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"yjs": "^13.6.29",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,9 +64,10 @@
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"prisma": "^6.3.0",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "is_private" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "auto_upgrade_pull_services" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "auto_upgrade_schedule" TEXT NOT NULL DEFAULT 'daily-3am',
|
||||
ADD COLUMN "enable_auto_upgrade" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "notify_admin_auto_upgrade" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'BROADCAST_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'CONTENT_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'MEDIA_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'PAYMENTS_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'EVENTS_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'SOCIAL_ADMIN';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "UserRole" ADD VALUE 'BROADCAST_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE 'CONTENT_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE 'MEDIA_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE 'PAYMENTS_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE 'EVENTS_ADMIN';
|
||||
ALTER TYPE "UserRole" ADD VALUE 'SOCIAL_ADMIN';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "enable_docs_collaboration" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "doc_collab_state" (
|
||||
"id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"state" BYTEA NOT NULL,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "doc_collab_state_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "doc_collab_state_document_id_key" ON "doc_collab_state"("document_id");
|
||||
@@ -15,6 +15,12 @@ enum UserRole {
|
||||
SUPER_ADMIN
|
||||
INFLUENCE_ADMIN
|
||||
MAP_ADMIN
|
||||
BROADCAST_ADMIN
|
||||
CONTENT_ADMIN
|
||||
MEDIA_ADMIN
|
||||
PAYMENTS_ADMIN
|
||||
EVENTS_ADMIN
|
||||
SOCIAL_ADMIN
|
||||
USER
|
||||
TEMP
|
||||
}
|
||||
@@ -935,6 +941,7 @@ model SiteSettings {
|
||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
||||
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
||||
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
|
||||
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||
|
||||
@@ -974,6 +981,12 @@ model SiteSettings {
|
||||
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")
|
||||
reengagementCooldownDays Int @default(30) @map("reengagement_cooldown_days")
|
||||
|
||||
// Auto-upgrade settings
|
||||
enableAutoUpgrade Boolean @default(false) @map("enable_auto_upgrade")
|
||||
autoUpgradeSchedule String @default("daily-3am") @map("auto_upgrade_schedule")
|
||||
autoUpgradePullServices Boolean @default(false) @map("auto_upgrade_pull_services")
|
||||
notifyAdminAutoUpgrade Boolean @default(true) @map("notify_admin_auto_upgrade")
|
||||
|
||||
// Navigation configuration (JSON: { items: NavItem[] })
|
||||
navConfig Json? @map("nav_config")
|
||||
|
||||
@@ -4383,6 +4396,7 @@ model SchedulingPoll {
|
||||
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
||||
votingDeadline DateTime? @map("voting_deadline")
|
||||
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
||||
isPrivate Boolean @default(false) @map("is_private")
|
||||
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
|
||||
@@ -5093,3 +5107,17 @@ model CalendarExportToken {
|
||||
@@index([userId], map: "idx_calendar_export_tokens_user")
|
||||
@@map("calendar_export_tokens")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCS COLLABORATION
|
||||
// ============================================================================
|
||||
|
||||
model DocCollabState {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id") // file path, e.g. "admin/index.md"
|
||||
state Bytes // Y.Doc binary state
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("doc_collab_state")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ export function requireRole(...roles: UserRole[]) {
|
||||
|
||||
// Check multi-role array (falls back to single role via auth middleware)
|
||||
const userRoles = req.user.roles || [req.user.role];
|
||||
|
||||
// SUPER_ADMIN bypasses all role checks
|
||||
if (userRoles.includes(UserRole.SUPER_ADMIN)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const hasRole = userRoles.some(r => roles.includes(r));
|
||||
|
||||
if (!hasRole) {
|
||||
|
||||
@@ -215,6 +215,18 @@ export const authService = {
|
||||
throw new AppError(401, 'Refresh token not found', 'INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
// Check user status — banned/inactive users must not get new tokens
|
||||
if (stored.user.status !== UserStatus.ACTIVE) {
|
||||
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
throw new AppError(401, 'Account is not active', 'ACCOUNT_INACTIVE');
|
||||
}
|
||||
|
||||
// Check account expiry
|
||||
if (stored.user.expiresAt && stored.user.expiresAt < new Date()) {
|
||||
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
throw new AppError(401, 'Account has expired', 'ACCOUNT_EXPIRED');
|
||||
}
|
||||
|
||||
if (stored.expiresAt < new Date()) {
|
||||
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
throw new AppError(401, 'Refresh token expired', 'REFRESH_TOKEN_EXPIRED');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { adminCalendarService } from './admin-calendar.service';
|
||||
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
|
||||
@@ -9,7 +10,7 @@ import { dateRangeQuerySchema } from './shared-calendar.schemas';
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
|
||||
router.use(requireRole(...EVENTS_ROLES));
|
||||
|
||||
// List admin calendar views
|
||||
router.get('/', async (req, res, next) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import crypto from 'crypto';
|
||||
import dns from 'dns/promises';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
CalendarLayerType,
|
||||
CalendarVisibility,
|
||||
@@ -20,6 +22,71 @@ const FETCH_TIMEOUT_MS = 30_000;
|
||||
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
const MATERIALIZE_MONTHS = 3;
|
||||
|
||||
// SSRF protection: block requests to private/reserved IP ranges and internal hosts
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost', '0.0.0.0', '[::]', '[::1]',
|
||||
// Common Docker internal hostnames
|
||||
'changemaker-v2-postgres', 'redis-changemaker', 'changemaker-v2-api',
|
||||
'changemaker-v2-admin', 'changemaker-v2-nginx', 'changemaker-v2-nocodb',
|
||||
'listmonk-app', 'listmonk-db', 'mailhog-changemaker',
|
||||
]);
|
||||
|
||||
function isPrivateIP(ip: string): boolean {
|
||||
// IPv4 private/reserved ranges
|
||||
if (ip.startsWith('10.')) return true;
|
||||
if (ip.startsWith('127.')) return true;
|
||||
if (ip.startsWith('169.254.')) return true; // Link-local / cloud metadata
|
||||
if (ip.startsWith('172.')) {
|
||||
const second = parseInt(ip.split('.')[1], 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
if (ip.startsWith('192.168.')) return true;
|
||||
if (ip === '0.0.0.0') return true;
|
||||
// IPv6 private/reserved
|
||||
if (ip === '::1' || ip === '::') return true;
|
||||
if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // ULA
|
||||
if (ip.startsWith('fe80')) return true; // Link-local
|
||||
return false;
|
||||
}
|
||||
|
||||
async function validateFeedUrl(rawUrl: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new AppError(400, 'Invalid URL format', 'INVALID_FEED_URL');
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new AppError(400, 'Only http and https URLs are allowed', 'INVALID_FEED_URL');
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
||||
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
|
||||
}
|
||||
|
||||
// Resolve DNS and check all resolved IPs
|
||||
try {
|
||||
const addrs4 = await dns.resolve4(hostname).catch(() => [] as string[]);
|
||||
const addrs6 = await dns.resolve6(hostname).catch(() => [] as string[]);
|
||||
const allAddrs = [...addrs4, ...addrs6];
|
||||
|
||||
if (allAddrs.length === 0) {
|
||||
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
|
||||
}
|
||||
|
||||
for (const addr of allAddrs) {
|
||||
if (isPrivateIP(addr)) {
|
||||
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
|
||||
}
|
||||
}
|
||||
|
||||
// Map CalendarFeedInterval to milliseconds
|
||||
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
|
||||
FIFTEEN_MIN: 15 * 60 * 1000,
|
||||
@@ -42,6 +109,9 @@ export const feedService = {
|
||||
},
|
||||
|
||||
async createFeed(userId: string, data: CreateFeedInput) {
|
||||
// SSRF protection: validate URL before making any request
|
||||
await validateFeedUrl(data.url);
|
||||
|
||||
// Validate URL is reachable
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -49,10 +119,10 @@ export const feedService = {
|
||||
const res = await fetch(data.url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok && res.status !== 405) {
|
||||
if (!res.ok && res.status !== 405 && !(res.status >= 300 && res.status < 400)) {
|
||||
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -114,7 +184,11 @@ export const feedService = {
|
||||
data: { name: data.name },
|
||||
});
|
||||
}
|
||||
if (data.url !== undefined) updateData.url = data.url;
|
||||
if (data.url !== undefined) {
|
||||
// SSRF protection: validate new URL before saving
|
||||
await validateFeedUrl(data.url);
|
||||
updateData.url = data.url;
|
||||
}
|
||||
if (data.refreshInterval !== undefined) {
|
||||
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
|
||||
}
|
||||
@@ -148,9 +222,12 @@ export const feedService = {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
// Re-validate the stored URL in case it was changed outside the update flow
|
||||
await validateFeedUrl(feed.url);
|
||||
|
||||
const response = await fetch(feed.url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
redirect: 'manual', // Don't follow redirects (SSRF protection)
|
||||
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { ADMIN_ROLES } from '../../utils/roles';
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getSystemInfo,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
|
||||
// GET /api/dashboard/summary — platform counts
|
||||
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
|
||||
import { docsAnalyticsService } from './docs-analytics.service';
|
||||
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
|
||||
// --- Public Router (no auth) ---
|
||||
export const docsAnalyticsPublicRouter = Router();
|
||||
@@ -47,7 +45,7 @@ docsAnalyticsPublicRouter.post(
|
||||
// --- Admin Router (auth required) ---
|
||||
export const docsAnalyticsAdminRouter = Router();
|
||||
docsAnalyticsAdminRouter.use(authenticate);
|
||||
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
docsAnalyticsAdminRouter.use(requireRole(...CONTENT_ROLES));
|
||||
|
||||
// GET /api/docs-analytics/summary?days=30
|
||||
docsAnalyticsAdminRouter.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
@@ -19,8 +18,7 @@ import {
|
||||
moderationQuerySchema,
|
||||
} from './docs-comments.schemas';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
|
||||
// --- Public Router (CORS override for docs origin) ---
|
||||
export const docsCommentsPublicRouter = Router();
|
||||
@@ -195,7 +193,7 @@ docsCommentsPublicRouter.get('/oauth/config', async (_req, res) => {
|
||||
// --- Admin Router (auth required) ---
|
||||
export const docsCommentsAdminRouter = Router();
|
||||
docsCommentsAdminRouter.use(authenticate);
|
||||
docsCommentsAdminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
docsCommentsAdminRouter.use(requireRole(...CONTENT_ROLES));
|
||||
|
||||
// GET /api/docs-comments/moderation?status=PENDING&page=1
|
||||
docsCommentsAdminRouter.get(
|
||||
|
||||
351
api/src/modules/docs/docs-collab.service.ts
Normal file
351
api/src/modules/docs/docs-collab.service.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { IncomingMessage } from 'http';
|
||||
import crypto from 'crypto';
|
||||
import type WebSocket from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import * as Y from 'yjs';
|
||||
import { Hocuspocus } from '@hocuspocus/server';
|
||||
import type { Extension } from '@hocuspocus/server';
|
||||
import { env } from '../../config/env';
|
||||
import { prisma } from '../../config/database';
|
||||
import { redis } from '../../config/redis';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
import { docsFilesService } from './docs-files.service';
|
||||
|
||||
// --- Metrics ---
|
||||
import { Gauge } from 'prom-client';
|
||||
|
||||
const collabConnections = new Gauge({
|
||||
name: 'cm_docs_collab_connections',
|
||||
help: 'Number of active docs collaboration WebSocket connections',
|
||||
});
|
||||
const collabDocuments = new Gauge({
|
||||
name: 'cm_docs_collab_documents',
|
||||
help: 'Number of active collaboratively-edited documents',
|
||||
});
|
||||
|
||||
// --- Connection tracking ---
|
||||
const connectionsPerUser = new Map<string, number>();
|
||||
const MAX_CONNECTIONS_PER_USER = 5;
|
||||
const MAX_CONCURRENT_DOCUMENTS = 50;
|
||||
const MAX_DOC_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// --- JWT token payload ---
|
||||
interface TokenPayload {
|
||||
id: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
roles?: UserRole[];
|
||||
}
|
||||
|
||||
// --- Deterministic color from user ID ---
|
||||
const COLLAB_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
|
||||
'#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
|
||||
];
|
||||
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
|
||||
}
|
||||
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
|
||||
}
|
||||
|
||||
// --- Redis cache key helper ---
|
||||
function fileCacheKey(relativePath: string): string {
|
||||
const hash = crypto.createHash('sha256').update(relativePath).digest('hex').substring(0, 16);
|
||||
return `DOCS_CACHE:file:${hash}`;
|
||||
}
|
||||
|
||||
// --- Hocuspocus extension with hooks ---
|
||||
const docsExtension: Extension = {
|
||||
priority: 1,
|
||||
|
||||
async onAuthenticate(data) {
|
||||
const { token, documentName } = data;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
||||
} catch {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const roles = payload.roles || [payload.role];
|
||||
|
||||
// Check CONTENT_ROLES for write access
|
||||
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
|
||||
if (!hasWriteAccess) {
|
||||
// Allow read-only for any authenticated non-TEMP user
|
||||
if (roles.includes(UserRole.TEMP)) {
|
||||
throw new Error('TEMP users cannot access collaboration');
|
||||
}
|
||||
data.connectionConfig.readOnly = true;
|
||||
}
|
||||
|
||||
// Validate document path (prevent path traversal)
|
||||
try {
|
||||
docsFilesService.safeResolve(documentName);
|
||||
} catch {
|
||||
throw new Error('Invalid document path');
|
||||
}
|
||||
|
||||
// Rate limit: max connections per user
|
||||
const currentCount = connectionsPerUser.get(payload.id) || 0;
|
||||
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
|
||||
throw new Error('Too many concurrent connections');
|
||||
}
|
||||
|
||||
// Rate limit: max concurrent documents
|
||||
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
|
||||
// Only block if this is a NEW document (not joining existing)
|
||||
if (!hocuspocus.documents.has(documentName)) {
|
||||
throw new Error('Too many concurrent documents');
|
||||
}
|
||||
}
|
||||
|
||||
// Track connection
|
||||
connectionsPerUser.set(payload.id, currentCount + 1);
|
||||
|
||||
// Look up user name from DB
|
||||
let userName = payload.email.split('@')[0];
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.id },
|
||||
select: { name: true },
|
||||
});
|
||||
if (user?.name) userName = user.name;
|
||||
} catch {
|
||||
// Fall back to email prefix
|
||||
}
|
||||
|
||||
// Set context for use in other hooks
|
||||
data.context.user = {
|
||||
id: payload.id,
|
||||
email: payload.email,
|
||||
name: userName,
|
||||
color: getUserColor(payload.id),
|
||||
roles,
|
||||
};
|
||||
|
||||
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
|
||||
},
|
||||
|
||||
async onLoadDocument(data) {
|
||||
const { document, documentName } = data;
|
||||
// Try loading persisted Y.Doc state from DB
|
||||
try {
|
||||
const stored = await prisma.docCollabState.findUnique({
|
||||
where: { documentId: documentName },
|
||||
});
|
||||
|
||||
if (stored) {
|
||||
const stateVector = new Uint8Array(stored.state);
|
||||
Y.applyUpdate(document, stateVector);
|
||||
logger.debug(`Docs collab: loaded persisted state for ${documentName} (${stateVector.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Docs collab: failed to load persisted state for ${documentName}`, err);
|
||||
}
|
||||
|
||||
// No persisted state — seed from disk
|
||||
try {
|
||||
const content = await docsFilesService.readFileContent(documentName);
|
||||
const yText = document.getText('content');
|
||||
// Only seed if the Y.Text is empty (first load)
|
||||
if (yText.length === 0) {
|
||||
yText.insert(0, content);
|
||||
}
|
||||
logger.debug(`Docs collab: seeded from disk for ${documentName} (${content.length} chars)`);
|
||||
} catch (err) {
|
||||
logger.warn(`Docs collab: failed to read file from disk for ${documentName}`, err);
|
||||
// File might not exist yet — that's OK, start with empty doc
|
||||
}
|
||||
},
|
||||
|
||||
async onChange(data) {
|
||||
const { document, documentName } = data;
|
||||
|
||||
// Size guard
|
||||
const yText = document.getText('content');
|
||||
const textLength = yText.length;
|
||||
if (textLength > MAX_DOC_SIZE_BYTES) {
|
||||
logger.warn(`Docs collab: document ${documentName} exceeds size limit (${textLength} chars)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write plaintext to disk (debounced by Hocuspocus's built-in debounce)
|
||||
const content = yText.toString();
|
||||
try {
|
||||
await docsFilesService.writeFileContent(documentName, content);
|
||||
// Invalidate Redis file cache
|
||||
try {
|
||||
await redis.del(fileCacheKey(documentName));
|
||||
} catch { /* ignore */ }
|
||||
logger.debug(`Docs collab: wrote ${documentName} to disk (${content.length} chars)`);
|
||||
} catch (err) {
|
||||
logger.error(`Docs collab: failed to write ${documentName} to disk`, err);
|
||||
}
|
||||
},
|
||||
|
||||
async onStoreDocument(data) {
|
||||
const { document, documentName } = data;
|
||||
|
||||
// Persist Y.Doc binary state to PostgreSQL
|
||||
try {
|
||||
const state = Y.encodeStateAsUpdate(document);
|
||||
|
||||
await prisma.docCollabState.upsert({
|
||||
where: { documentId: documentName },
|
||||
update: {
|
||||
state: Buffer.from(state),
|
||||
},
|
||||
create: {
|
||||
documentId: documentName,
|
||||
state: Buffer.from(state),
|
||||
},
|
||||
});
|
||||
logger.debug(`Docs collab: persisted state for ${documentName}`);
|
||||
} catch (err) {
|
||||
logger.error(`Docs collab: failed to persist state for ${documentName}`, err);
|
||||
}
|
||||
},
|
||||
|
||||
async onDisconnect(data) {
|
||||
const { documentName, context } = data;
|
||||
const userId = context?.user?.id;
|
||||
if (userId) {
|
||||
const count = connectionsPerUser.get(userId) || 0;
|
||||
if (count <= 1) {
|
||||
connectionsPerUser.delete(userId);
|
||||
} else {
|
||||
connectionsPerUser.set(userId, count - 1);
|
||||
}
|
||||
}
|
||||
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} disconnected from ${documentName}`);
|
||||
|
||||
// Update metrics
|
||||
collabConnections.set(hocuspocus.getConnectionsCount());
|
||||
collabDocuments.set(hocuspocus.getDocumentsCount());
|
||||
},
|
||||
|
||||
async connected(data) {
|
||||
const { documentName, context } = data;
|
||||
|
||||
// Awareness is set client-side; we just log here
|
||||
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} fully synced on ${documentName}`);
|
||||
|
||||
// Update metrics
|
||||
collabConnections.set(hocuspocus.getConnectionsCount());
|
||||
collabDocuments.set(hocuspocus.getDocumentsCount());
|
||||
},
|
||||
};
|
||||
|
||||
// --- Create Hocuspocus instance ---
|
||||
const hocuspocus = new Hocuspocus({
|
||||
name: 'changemaker-docs',
|
||||
quiet: true,
|
||||
debounce: 1000, // Debounce disk writes by 1s
|
||||
maxDebounce: 5000, // Force write every 5s at most
|
||||
timeout: 30000, // 30s ping timeout
|
||||
extensions: [docsExtension],
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket connection for docs collaboration.
|
||||
* Called from server.ts after the HTTP → WS upgrade.
|
||||
*/
|
||||
function handleConnection(
|
||||
ws: WebSocket,
|
||||
request: IncomingMessage,
|
||||
context: { documentName: string; token: string },
|
||||
): void {
|
||||
// Hocuspocus expects the token in the URL search params for onAuthenticate
|
||||
// We manually set it via the context's requestParameters
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
url.searchParams.set('token', context.token);
|
||||
request.url = url.pathname + url.search;
|
||||
|
||||
hocuspocus.handleConnection(ws, request, {
|
||||
// Pass initial context
|
||||
documentName: context.documentName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a document's collaboration state.
|
||||
* Called when files are modified externally (rename, delete, PUT endpoint).
|
||||
*/
|
||||
async function invalidateDocument(documentName: string): Promise<void> {
|
||||
try {
|
||||
// Delete persisted state
|
||||
await prisma.docCollabState.deleteMany({
|
||||
where: { documentId: documentName },
|
||||
});
|
||||
// Close active connections for this document so clients reload
|
||||
hocuspocus.closeConnections(documentName);
|
||||
logger.debug(`Docs collab: invalidated document ${documentName}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Docs collab: failed to invalidate document ${documentName}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale DocCollabState records (older than 7 days with no corresponding file).
|
||||
*/
|
||||
async function cleanupStaleStates(): Promise<void> {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
try {
|
||||
const staleRecords = await prisma.docCollabState.findMany({
|
||||
where: { updatedAt: { lt: sevenDaysAgo } },
|
||||
select: { id: true, documentId: true },
|
||||
});
|
||||
|
||||
let cleaned = 0;
|
||||
for (const record of staleRecords) {
|
||||
try {
|
||||
// Check if file still exists on disk
|
||||
docsFilesService.safeResolve(record.documentId);
|
||||
await docsFilesService.readFileContent(record.documentId);
|
||||
} catch {
|
||||
// File doesn't exist — delete the state
|
||||
await prisma.docCollabState.delete({ where: { id: record.id } });
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
logger.info(`Docs collab: cleaned up ${cleaned} stale collaboration states`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Docs collab: cleanup failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close all connections and stop Hocuspocus.
|
||||
*/
|
||||
async function shutdown(): Promise<void> {
|
||||
hocuspocus.closeConnections();
|
||||
collabConnections.set(0);
|
||||
collabDocuments.set(0);
|
||||
}
|
||||
|
||||
export const docsCollabService = {
|
||||
handleConnection,
|
||||
invalidateDocument,
|
||||
cleanupStaleStates,
|
||||
shutdown,
|
||||
getConnectionsCount: () => hocuspocus.getConnectionsCount(),
|
||||
getDocumentsCount: () => hocuspocus.getDocumentsCount(),
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import { extname, basename } from 'path';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||
import { env } from '../../config/env';
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { isServiceOnline } from '../../utils/health-check';
|
||||
import { cm_docs_operations } from '../../utils/metrics';
|
||||
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
|
||||
import { docsCollabService } from './docs-collab.service';
|
||||
import { mkdocsConfigService } from './mkdocs-config.service';
|
||||
import { headerBuilderService } from './header-builder.service';
|
||||
import { headerConfigSchema } from './header-builder.schemas';
|
||||
@@ -73,7 +75,7 @@ router.get(
|
||||
// PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only)
|
||||
router.put(
|
||||
'/mkdocs-config',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { content } = req.body as { content?: string };
|
||||
@@ -94,10 +96,10 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/docs/build — trigger mkdocs build in container (SUPER_ADMIN only)
|
||||
// POST /api/docs/build — trigger mkdocs build in container
|
||||
router.post(
|
||||
'/build',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await mkdocsConfigService.triggerBuild();
|
||||
@@ -128,7 +130,7 @@ router.get(
|
||||
// PUT /api/docs/header-config — save header nav bar config + regenerate template
|
||||
router.put(
|
||||
'/header-config',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsed = headerConfigSchema.safeParse(req.body);
|
||||
@@ -172,7 +174,7 @@ const upload = multer({
|
||||
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
||||
router.post(
|
||||
'/upload',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
upload.single('file'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const tempPath = req.file?.path;
|
||||
@@ -244,7 +246,7 @@ router.get(
|
||||
// POST /api/docs/files/rename — rename/move file
|
||||
router.post(
|
||||
'/files/rename',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'rename' });
|
||||
@@ -254,6 +256,8 @@ router.post(
|
||||
return;
|
||||
}
|
||||
await docsFilesService.renameFile(from, to);
|
||||
// Invalidate old path's collaboration state
|
||||
docsCollabService.invalidateDocument(from).catch(() => {});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleFileError(err, res, next);
|
||||
@@ -283,7 +287,7 @@ router.get(
|
||||
// PUT /api/docs/files/* — write/update file content
|
||||
router.put(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'write' });
|
||||
@@ -298,6 +302,8 @@ router.put(
|
||||
return;
|
||||
}
|
||||
await docsFilesService.writeFileContent(filePath, content);
|
||||
// Invalidate collaboration state so next session starts fresh from disk
|
||||
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||
res.json({ success: true, path: filePath });
|
||||
} catch (err) {
|
||||
handleFileError(err, res, next);
|
||||
@@ -308,7 +314,7 @@ router.put(
|
||||
// POST /api/docs/files/* — create new file or folder
|
||||
router.post(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'create' });
|
||||
@@ -329,7 +335,7 @@ router.post(
|
||||
// DELETE /api/docs/files/* — delete file or empty folder
|
||||
router.delete(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'delete' });
|
||||
@@ -339,6 +345,8 @@ router.delete(
|
||||
return;
|
||||
}
|
||||
await docsFilesService.deleteFile(filePath);
|
||||
// Invalidate collaboration state for deleted file
|
||||
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleFileError(err, res, next);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { BROADCAST_ROLES } from '../../utils/roles';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { redis } from '../../config/redis';
|
||||
@@ -24,8 +25,8 @@ const router = Router();
|
||||
// All email template routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// All routes require admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
|
||||
const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN);
|
||||
// All routes require broadcast admin role
|
||||
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
|
||||
|
||||
/**
|
||||
* List email templates
|
||||
@@ -33,7 +34,7 @@ const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_AD
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
validate(listEmailTemplatesSchema, 'query'),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -52,7 +53,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const template = await emailTemplatesService.getById(req.params.id as string);
|
||||
@@ -74,7 +75,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
validate(createEmailTemplateSchema),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -101,7 +102,7 @@ router.post(
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
validate(updateEmailTemplateSchema),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -133,7 +134,7 @@ router.put(
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Fetch template before deleting to get the key
|
||||
@@ -167,7 +168,7 @@ router.delete(
|
||||
*/
|
||||
router.get(
|
||||
'/:id/versions',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const versions = await emailTemplatesService.getVersions(req.params.id as string);
|
||||
@@ -185,7 +186,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/:id/versions/:versionNumber',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const version = await emailTemplatesService.getVersion(
|
||||
@@ -210,7 +211,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/:id/rollback',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
validate(rollbackToVersionSchema),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -237,7 +238,7 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/validate',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
validate(validateTemplateSchema),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -257,7 +258,7 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/:id/test',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
@@ -291,7 +292,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/:id/test-logs',
|
||||
requireAdminRole,
|
||||
requireBroadcastRole,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { galleryAdsService } from './gallery-ads.service';
|
||||
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(UserRole.SUPER_ADMIN));
|
||||
router.use(requireRole(...PAYMENTS_ROLES));
|
||||
|
||||
// GET /api/gallery-ads/admin — list all ads (paginated)
|
||||
router.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { campaignEmailsService } from './campaign-emails.service';
|
||||
import {
|
||||
sendCampaignEmailSchema,
|
||||
@@ -10,8 +9,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { emailRateLimit } from '../../../middleware/rate-limit';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
// --- Public Routes (no auth) ---
|
||||
const publicRouter = Router();
|
||||
@@ -53,7 +51,7 @@ publicRouter.post(
|
||||
// --- Admin Routes (auth required) ---
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/campaigns/:id/emails
|
||||
adminRouter.get(
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { campaignsService } from './campaigns.service';
|
||||
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/campaigns/moderation/queue — list moderation queue
|
||||
router.get(
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { campaignsService } from './campaigns.service';
|
||||
import { createCampaignSchema, updateCampaignSchema, listCampaignsSchema } from './campaigns.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All campaign admin routes require authentication + admin role
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/campaigns — list campaigns with pagination/filters
|
||||
router.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
@@ -10,12 +9,11 @@ import {
|
||||
geoQuerySchema,
|
||||
repQuerySchema,
|
||||
} from './effectiveness.schemas';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/influence/effectiveness/overview
|
||||
router.get(
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { emailQueueService } from '../../../services/email-queue.service';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/email-queue/stats
|
||||
router.get(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { representativesService } from './representatives.service';
|
||||
import { listRepresentativesSchema } from './representatives.schemas';
|
||||
import { postalCodeParamSchema, postalCodeQuerySchema } from '../postal-codes/postal-codes.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -50,7 +48,7 @@ router.get(
|
||||
// =============================================
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/representatives/cache-stats — cache statistics
|
||||
router.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { responsesService } from './responses.service';
|
||||
import {
|
||||
submitResponseSchema,
|
||||
@@ -12,8 +11,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { optionalAuth } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { responseRateLimit } from '../../../middleware/rate-limit';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
|
||||
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
|
||||
const campaignPublicRouter = Router();
|
||||
@@ -144,7 +142,7 @@ responsesPublicRouter.get(
|
||||
// --- Admin routes (mount at /api/responses) ---
|
||||
const responsesAdminRouter = Router();
|
||||
responsesAdminRouter.use(authenticate);
|
||||
responsesAdminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
responsesAdminRouter.use(requireRole(...INFLUENCE_ROLES));
|
||||
|
||||
// GET /api/responses
|
||||
responsesAdminRouter.get(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { listmonkClient } from '../../services/listmonk.client';
|
||||
import { listmonkSyncService } from '../../services/listmonk-sync.service';
|
||||
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
||||
import { env } from '../../config/env';
|
||||
import { BROADCAST_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(UserRole.SUPER_ADMIN));
|
||||
router.use(requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/listmonk — sync status
|
||||
router.get(
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
exportContactsToCampaign,
|
||||
getCutCampaignAnalytics,
|
||||
} from './canvass-export.service';
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
router.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// POST /api/map/canvass/export-contacts/preview — preview matching contacts
|
||||
router.post(
|
||||
|
||||
@@ -20,8 +20,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||
const volunteerRouter = Router();
|
||||
@@ -282,7 +281,7 @@ volunteerRouter.post(
|
||||
// ─── Admin Router ────────────────────────────────────────────────────
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/canvass/stats
|
||||
adminRouter.get(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import multer from 'multer';
|
||||
import { cutsService } from './cuts.service';
|
||||
import { createCutSchema, updateCutSchema, listCutsSchema } from './cuts.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const geojsonUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -19,12 +19,10 @@ const geojsonUpload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
|
||||
// --- Admin Router ---
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/cuts — list paginated
|
||||
adminRouter.get(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { geocodingService } from './geocoding.service';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const searchSchema = z.object({
|
||||
q: z.string().min(2, 'Query must be at least 2 characters'),
|
||||
@@ -15,7 +13,7 @@ const searchSchema = z.object({
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
router.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/geocoding/search?q=Ottawa&limit=5
|
||||
router.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
@@ -8,12 +7,11 @@ import { areaImportPreviewSchema, areaImportStartSchema } from './area-import.sc
|
||||
import { areaImportService, type AreaImportProgress } from './area-import.service';
|
||||
import { redis } from '../../../config/redis';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const areaImportRouter = Router();
|
||||
areaImportRouter.use(authenticate);
|
||||
areaImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
areaImportRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
|
||||
areaImportRouter.post(
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { geocodeQueueService } from '../../../services/geocode-queue.service';
|
||||
import { bulkGeocodeSchema } from './bulk-geocode.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
router.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// POST /api/map/locations/bulk-geocode — start bulk geocoding job
|
||||
router.post(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import multer from 'multer';
|
||||
import { locationsService } from './locations.service';
|
||||
import {
|
||||
@@ -17,8 +16,7 @@ import { prisma } from '../../../config/database';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
// Multer config for CSV upload (memory storage, 10MB limit)
|
||||
const upload = multer({
|
||||
@@ -49,7 +47,7 @@ const bulkUpload = multer({
|
||||
// --- Admin Router ---
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/locations — list with pagination + filters
|
||||
adminRouter.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { narImportService, writeProgress } from './nar-import.service';
|
||||
@@ -8,8 +7,7 @@ import { logger } from '../../../utils/logger';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const serverImportSchema = z.object({
|
||||
provinceCode: z.string().min(1).max(2),
|
||||
@@ -24,7 +22,7 @@ const serverImportSchema = z.object({
|
||||
|
||||
const narImportRouter = Router();
|
||||
narImportRouter.use(authenticate);
|
||||
narImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
narImportRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/nar-import/datasets — list available NAR datasets by province
|
||||
narImportRouter.get(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { mapSettingsService } from './settings.service';
|
||||
import { updateMapSettingsSchema } from './settings.schemas';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -27,7 +25,7 @@ router.get(
|
||||
router.put(
|
||||
'/',
|
||||
authenticate,
|
||||
requireRole(...MAP_ADMIN_ROLES),
|
||||
requireRole(...MAP_ROLES),
|
||||
validate(updateMapSettingsSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Router } from 'express';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { ShiftSeriesService } from './shift-series.service';
|
||||
import { createShiftSeriesSchema, updateShiftSeriesSchema } from './shift-series.schemas';
|
||||
import { SCHEDULING_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN));
|
||||
router.use(authenticate, requireRole(...SCHEDULING_ROLES));
|
||||
|
||||
// Create series
|
||||
router.post(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { shiftsService } from './shifts.service';
|
||||
import {
|
||||
createShiftSchema,
|
||||
@@ -14,13 +13,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { redis } from '../../../config/redis';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { SCHEDULING_ROLES } from '../../../utils/roles';
|
||||
|
||||
// --- Admin Router ---
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...SCHEDULING_ROLES));
|
||||
|
||||
// GET /api/map/shifts — list paginated
|
||||
adminRouter.get(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { trackingService } from './tracking.service';
|
||||
import {
|
||||
startTrackingSchema,
|
||||
@@ -13,8 +12,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
|
||||
|
||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||
const volunteerRouter = Router();
|
||||
@@ -135,7 +133,7 @@ volunteerRouter.get(
|
||||
// ─── Admin Router ────────────────────────────────────────────────────
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...MAP_ROLES));
|
||||
|
||||
// GET /api/map/tracking/live — active volunteers with positions + recent trails
|
||||
adminRouter.get(
|
||||
|
||||
@@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { UserRole, UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { env } from '../../../config/env';
|
||||
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
|
||||
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
|
||||
|
||||
// Extend FastifyRequest to include user
|
||||
declare module 'fastify' {
|
||||
@@ -123,7 +123,7 @@ export async function requireAdminRole(
|
||||
}
|
||||
|
||||
// Check admin role using multi-role utility
|
||||
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
|
||||
if (!request.user || !hasAnyRole(request.user, MEDIA_ROLES)) {
|
||||
return reply.status(403).send({
|
||||
error: 'Admin access required',
|
||||
code: 'ADMIN_REQUIRED'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { prisma } from '../../../config/database';
|
||||
import { env } from '../../../config/env';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
|
||||
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
||||
import { unlink } from 'fs/promises';
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
||||
roles?: UserRole[];
|
||||
};
|
||||
|
||||
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
|
||||
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.id },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UserRole, UserStatus } from '@prisma/client';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { env } from '../../../config/env';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
|
||||
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
||||
|
||||
/**
|
||||
* Check if the request is from an authenticated admin user.
|
||||
@@ -37,7 +37,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
||||
};
|
||||
|
||||
// Check admin role from token (multi-role aware)
|
||||
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
|
||||
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
||||
|
||||
// Verify user is still active in DB
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { meetingPlannerService } from './meeting-planner.service';
|
||||
import {
|
||||
createPollSchema,
|
||||
@@ -13,17 +12,16 @@ import {
|
||||
listPollsSchema,
|
||||
} from './meeting-planner.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
// --- Admin Router ---
|
||||
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...ADMIN_ROLES));
|
||||
adminRouter.use(requireRole(...EVENTS_ROLES));
|
||||
|
||||
// List polls
|
||||
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -151,7 +149,7 @@ const publicRouter = Router();
|
||||
// Public listing of open polls
|
||||
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await meetingPlannerService.findAll({
|
||||
const result = await meetingPlannerService.findAllPublic({
|
||||
status: 'OPEN',
|
||||
limit: 50,
|
||||
page: 1,
|
||||
@@ -161,51 +159,28 @@ publicRouter.get('/public', async (req: Request, res: Response, next: NextFuncti
|
||||
});
|
||||
|
||||
// View poll by slug
|
||||
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
|
||||
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const poll = await meetingPlannerService.findBySlug(slug);
|
||||
const poll = await meetingPlannerService.findBySlugPublic(slug, req.user?.id);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Submit votes
|
||||
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
publicRouter.post('/public/:slug/vote', optionalAuth, pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
// Try to get userId from optional auth header
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { env } = await import('../../config/env');
|
||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||
userId = decoded.id;
|
||||
}
|
||||
} catch { /* not authenticated, that's fine */ }
|
||||
|
||||
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
|
||||
const result = await meetingPlannerService.submitVotes(slug, req.body, req.user?.id);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Add comment
|
||||
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
publicRouter.post('/public/:slug/comment', optionalAuth, pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { env } = await import('../../config/env');
|
||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||
userId = decoded.id;
|
||||
}
|
||||
} catch { /* not authenticated */ }
|
||||
|
||||
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
|
||||
const comment = await meetingPlannerService.addComment(slug, req.body, req.user?.id);
|
||||
res.status(201).json(comment);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export const createPollSchema = z.object({
|
||||
location: z.string().max(500).optional(),
|
||||
timezone: z.string().default('America/Edmonton'),
|
||||
allowAnonymous: z.boolean().optional().default(true),
|
||||
isPrivate: z.boolean().optional().default(false),
|
||||
notifyOnVote: z.boolean().optional().default(true),
|
||||
votingDeadline: z.string().datetime().optional(),
|
||||
options: z.array(z.object({
|
||||
@@ -22,6 +23,7 @@ export const updatePollSchema = z.object({
|
||||
location: z.string().max(500).nullable().optional(),
|
||||
timezone: z.string().optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
notifyOnVote: z.boolean().optional(),
|
||||
votingDeadline: z.string().datetime().nullable().optional(),
|
||||
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto';
|
||||
import { Prisma, PollVoteValue } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
@@ -22,6 +23,7 @@ const pollInclude = {
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
// Admin detail include — returns all vote fields (for admin endpoints)
|
||||
const pollDetailInclude = {
|
||||
options: {
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
@@ -34,6 +36,31 @@ const pollDetailInclude = {
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
// Public detail include — strips voterEmail and voterToken from votes
|
||||
const pollDetailPublicInclude = {
|
||||
options: {
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
include: {
|
||||
votes: {
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
select: {
|
||||
id: true,
|
||||
pollId: true,
|
||||
optionId: true,
|
||||
voterName: true,
|
||||
userId: true,
|
||||
value: true,
|
||||
createdAt: true,
|
||||
// voterEmail and voterToken intentionally excluded
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
comments: { orderBy: { createdAt: 'asc' as const } },
|
||||
createdBy: { select: { id: true, name: true } }, // exclude email from public
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
|
||||
return options.map((opt) => {
|
||||
let yesCount = 0;
|
||||
@@ -56,7 +83,7 @@ function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollV
|
||||
|
||||
function groupVotesByVoter(votes: Array<{
|
||||
voterName: string;
|
||||
voterToken: string | null;
|
||||
voterToken?: string | null;
|
||||
userId: string | null;
|
||||
optionId: string;
|
||||
value: PollVoteValue;
|
||||
@@ -139,6 +166,68 @@ export const meetingPlannerService = {
|
||||
return { ...poll, options: optionsWithCounts, voters };
|
||||
},
|
||||
|
||||
async findBySlugPublic(slug: string, userId?: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { slug },
|
||||
include: pollDetailPublicInclude,
|
||||
});
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
|
||||
// If private and not authenticated, return limited data
|
||||
if (poll.isPrivate && !userId) {
|
||||
return {
|
||||
id: poll.id,
|
||||
slug: poll.slug,
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
location: poll.location,
|
||||
status: poll.status,
|
||||
timezone: poll.timezone,
|
||||
allowAnonymous: poll.allowAnonymous,
|
||||
isPrivate: poll.isPrivate,
|
||||
notifyOnVote: poll.notifyOnVote,
|
||||
createdBy: poll.createdBy,
|
||||
createdByUserId: poll.createdByUserId,
|
||||
createdAt: poll.createdAt,
|
||||
updatedAt: poll.updatedAt,
|
||||
votingDeadline: poll.votingDeadline,
|
||||
finalizedOptionId: null,
|
||||
finalizedOption: null,
|
||||
convertedShiftId: null,
|
||||
convertedGancioEventId: null,
|
||||
requiresAuth: true,
|
||||
options: [],
|
||||
voters: [],
|
||||
comments: [],
|
||||
_count: { options: 0, votes: 0, comments: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const optionsWithCounts = aggregateVotes(poll.options);
|
||||
const allVotes = poll.options.flatMap((opt) =>
|
||||
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
|
||||
);
|
||||
const voters = groupVotesByVoter(allVotes);
|
||||
|
||||
return { ...poll, options: optionsWithCounts, voters, requiresAuth: false };
|
||||
},
|
||||
|
||||
async findAllPublic(filters: ListPollsInput) {
|
||||
const result = await this.findAll({ ...filters, status: 'OPEN' });
|
||||
return {
|
||||
...result,
|
||||
// Filter out private polls entirely from the public listing
|
||||
polls: result.polls
|
||||
.filter((poll) => !poll.isPrivate)
|
||||
.map((poll) => ({
|
||||
...poll,
|
||||
requiresAuth: false,
|
||||
// Strip organizer email from public listing
|
||||
createdBy: poll.createdBy ? { id: poll.createdBy.id, name: poll.createdBy.name } : null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async create(data: CreatePollInput, userId: string) {
|
||||
const slug = generateSlug(data.title);
|
||||
|
||||
@@ -150,6 +239,7 @@ export const meetingPlannerService = {
|
||||
location: data.location,
|
||||
timezone: data.timezone,
|
||||
allowAnonymous: data.allowAnonymous,
|
||||
isPrivate: data.isPrivate,
|
||||
notifyOnVote: data.notifyOnVote,
|
||||
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
||||
createdByUserId: userId,
|
||||
@@ -178,6 +268,7 @@ export const meetingPlannerService = {
|
||||
if (data.location !== undefined) updateData.location = data.location;
|
||||
if (data.timezone !== undefined) updateData.timezone = data.timezone;
|
||||
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
|
||||
if (data.isPrivate !== undefined) updateData.isPrivate = data.isPrivate;
|
||||
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
|
||||
if (data.votingDeadline !== undefined) {
|
||||
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
||||
@@ -263,6 +354,9 @@ export const meetingPlannerService = {
|
||||
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
|
||||
throw new AppError(400, 'The voting deadline has passed');
|
||||
}
|
||||
if (poll.isPrivate && !userId) {
|
||||
throw new AppError(401, 'This poll requires authentication to vote');
|
||||
}
|
||||
if (!poll.allowAnonymous && !userId) {
|
||||
throw new AppError(401, 'This poll requires authentication to vote');
|
||||
}
|
||||
@@ -332,6 +426,12 @@ export const meetingPlannerService = {
|
||||
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
|
||||
if (!poll) throw new AppError(404, 'Poll not found');
|
||||
if (poll.isPrivate && !userId) {
|
||||
throw new AppError(401, 'This poll requires authentication to comment');
|
||||
}
|
||||
if (!poll.allowAnonymous && !userId) {
|
||||
throw new AppError(401, 'This poll requires authentication to comment');
|
||||
}
|
||||
|
||||
return prisma.schedulingPollComment.create({
|
||||
data: {
|
||||
@@ -517,12 +617,7 @@ export const meetingPlannerService = {
|
||||
};
|
||||
|
||||
function generateVoterToken(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let token = '';
|
||||
for (let i = 0; i < 24; i++) {
|
||||
token += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return token;
|
||||
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { blocksService } from './blocks.service';
|
||||
import { createPageBlockSchema, updatePageBlockSchema, listPageBlocksSchema } from './pages.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...CONTENT_ROLES));
|
||||
|
||||
// GET /api/page-blocks — list all blocks
|
||||
router.get(
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { pagesService } from './pages.service';
|
||||
import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchema } from './pages.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { prisma } from '../../config/database';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole(...CONTENT_ROLES));
|
||||
|
||||
// GET /api/pages/view-counts — landing page view counts (last 30d)
|
||||
router.get(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { donationPagesService } from './donation-pages.service';
|
||||
import {
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require SUPER_ADMIN
|
||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
|
||||
// All routes require PAYMENTS_ROLES
|
||||
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
|
||||
|
||||
// GET /api/payments/admin/donation-pages — list with pagination, search, status
|
||||
router.get(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { paymentSettingsService } from './payment-settings.service';
|
||||
import { subscriptionsService } from './subscriptions.service';
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All admin routes require SUPER_ADMIN
|
||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
|
||||
// All admin routes require PAYMENTS_ROLES
|
||||
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
|
||||
|
||||
// =================== Settings ===================
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { giteaClient } from '../../services/gitea.client';
|
||||
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
|
||||
import { autoUpgradeService } from '../../services/auto-upgrade.service';
|
||||
import { headerBuilderService } from '../docs/header-builder.service';
|
||||
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,6 +36,7 @@ router.get(
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const settings = await siteSettingsService.getEffective();
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(settings);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -115,6 +117,12 @@ router.put(
|
||||
gancioSettingsSyncService.syncChanged(req.body).catch(() => {});
|
||||
}
|
||||
|
||||
// If auto-upgrade settings changed, restart the scheduler
|
||||
const autoUpgradeFields = ['enableAutoUpgrade', 'autoUpgradeSchedule', 'autoUpgradePullServices'];
|
||||
if (autoUpgradeFields.some((f) => f in req.body)) {
|
||||
autoUpgradeService.start().catch(() => {});
|
||||
}
|
||||
|
||||
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
|
||||
const headerTriggerFields = [
|
||||
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const updateSiteSettingsSchema = z.object({
|
||||
enableMeetingPlanner: z.boolean().optional(),
|
||||
enableTicketedEvents: z.boolean().optional(),
|
||||
enableSocialCalendar: z.boolean().optional(),
|
||||
enableDocsCollaboration: z.boolean().optional(),
|
||||
requireEventApproval: z.boolean().optional(),
|
||||
autoSyncPeopleToMap: z.boolean().optional(),
|
||||
|
||||
@@ -86,6 +87,15 @@ export const updateSiteSettingsSchema = z.object({
|
||||
provisionListmonk: z.boolean().optional(),
|
||||
provisionListmonkTiming: z.enum(['lazy', 'eager']).optional(),
|
||||
|
||||
// Auto-upgrade settings
|
||||
enableAutoUpgrade: z.boolean().optional(),
|
||||
autoUpgradeSchedule: z.enum([
|
||||
'daily-3am', 'daily-4am', 'daily-5am',
|
||||
'weekly-sun-3am', 'weekly-mon-3am', '12h', '24h',
|
||||
]).optional(),
|
||||
autoUpgradePullServices: z.boolean().optional(),
|
||||
notifyAdminAutoUpgrade: z.boolean().optional(),
|
||||
|
||||
// Navigation configuration (supports one level of nesting via groups)
|
||||
navConfig: z.object({
|
||||
items: z.array(z.object({
|
||||
|
||||
@@ -5,11 +5,12 @@ import { validate } from '../../../middleware/validate';
|
||||
import { smsCampaignsService } from './sms-campaigns.service';
|
||||
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
// All routes require authentication + broadcast admin role
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/sms/campaigns — list all campaigns
|
||||
router.get('/', async (req, res, next) => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { smsContactsService } from './sms-contacts.service';
|
||||
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
// All routes require authentication + broadcast admin role
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// --- Contact Lists ---
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Router } from 'express';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { smsConversationsService } from './sms-conversations.service';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/sms/conversations — list conversations
|
||||
router.get('/', async (req, res, next) => {
|
||||
|
||||
@@ -3,10 +3,11 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { smsDeviceService } from './sms-device.service';
|
||||
import { termuxClient } from '../../../services/termux.client';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/sms/device — latest device status
|
||||
router.get('/', async (_req, res, next) => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Router } from 'express';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { smsMessagesService } from './sms-messages.service';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/sms/messages — list all messages
|
||||
router.get('/', async (req, res, next) => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { smsTemplatesService } from './sms-templates.service';
|
||||
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
// All routes require authentication + broadcast admin role
|
||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||
|
||||
// GET /api/sms/templates — list with search/filter/pagination
|
||||
router.get('/', async (req, res, next) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { challengeService } from './challenge.service';
|
||||
import {
|
||||
createChallengeSchema,
|
||||
@@ -98,7 +99,7 @@ router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
|
||||
// ── Admin ────────────────────────────────────────────────────────────
|
||||
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
|
||||
adminRouter.use(requireRole(...SOCIAL_ROLES));
|
||||
|
||||
/** POST /admin — create challenge */
|
||||
adminRouter.post('/', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { INFLUENCE_ROLES } from '../../utils/roles';
|
||||
import { impactStoriesService } from './impact-stories.service';
|
||||
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
||||
|
||||
@@ -7,7 +8,7 @@ const router = Router();
|
||||
|
||||
// --- Admin routes (require admin role) ---
|
||||
|
||||
router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
router.post('/', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const data = createStorySchema.parse(req.body);
|
||||
const story = await impactStoriesService.create(data, req.user!.id);
|
||||
@@ -17,7 +18,7 @@ router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), asy
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
router.put('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const data = updateStorySchema.parse(req.body);
|
||||
const story = await impactStoriesService.update(req.params.id as string, data);
|
||||
@@ -27,7 +28,7 @@ router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), a
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
router.delete('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const result = await impactStoriesService.delete(req.params.id as string);
|
||||
res.json(result);
|
||||
@@ -36,7 +37,7 @@ router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN')
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
router.post('/:id/publish', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const story = await impactStoriesService.publish(req.params.id as string);
|
||||
// Fire-and-forget: notify participants
|
||||
@@ -47,7 +48,7 @@ router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_A
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/archive', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
router.post('/:id/archive', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const story = await impactStoriesService.archive(req.params.id as string);
|
||||
res.json(story);
|
||||
@@ -64,7 +65,7 @@ router.get('/', async (req, res, next) => {
|
||||
// Admin users can filter by status; regular users see published only
|
||||
const userRoles = req.user!.roles || [req.user!.role];
|
||||
const isAdmin = userRoles.some((r: string) =>
|
||||
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
|
||||
(INFLUENCE_ROLES as string[]).includes(r),
|
||||
);
|
||||
|
||||
if (isAdmin && (campaignId || status)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { referralService } from './referral.service';
|
||||
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
|
||||
|
||||
@@ -72,7 +73,7 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/admin/all — all referrals (admin only) */
|
||||
router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
|
||||
router.get('/admin/all', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page, limit } = paginationSchema.parse(req.query);
|
||||
const result = await referralService.listAllReferrals(page, limit);
|
||||
@@ -83,7 +84,7 @@ router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMI
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/admin/leaderboard — top referrers (admin only) */
|
||||
router.get('/admin/leaderboard', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
|
||||
router.get('/admin/leaderboard', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt((req.query.limit as string) || '10', 10);
|
||||
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { friendshipRouter } from './friendship.routes';
|
||||
import { blockRouter } from './block.routes';
|
||||
import { privacyRouter } from './privacy.routes';
|
||||
@@ -35,7 +36,7 @@ router.use((req, _res, next) => {
|
||||
router.use(authenticate);
|
||||
|
||||
// Admin sub-router (requires admin role)
|
||||
router.use('/admin', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), socialAdminRouter);
|
||||
router.use('/admin', requireRole(...SOCIAL_ROLES), socialAdminRouter);
|
||||
|
||||
// Sub-routers
|
||||
router.use('/friends', friendshipRouter);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { spotlightService } from './spotlight.service';
|
||||
import {
|
||||
nominateSchema,
|
||||
@@ -88,7 +89,7 @@ router.post('/opt-out', async (req: Request, res: Response, next: NextFunction)
|
||||
/** GET /api/social/spotlight/admin — list all spotlights */
|
||||
router.get(
|
||||
'/admin',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
|
||||
@@ -103,7 +104,7 @@ router.get(
|
||||
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
|
||||
router.post(
|
||||
'/admin/nominate',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = nominateSchema.parse(req.body);
|
||||
@@ -118,7 +119,7 @@ router.post(
|
||||
/** PUT /api/social/spotlight/admin/:id — update headline/story */
|
||||
router.put(
|
||||
'/admin/:id',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = updateSpotlightSchema.parse(req.body);
|
||||
@@ -133,7 +134,7 @@ router.put(
|
||||
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
|
||||
router.post(
|
||||
'/admin/:id/approve',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
|
||||
@@ -147,7 +148,7 @@ router.post(
|
||||
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
|
||||
router.post(
|
||||
'/admin/:id/feature',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = featureSchema.parse(req.body);
|
||||
@@ -162,7 +163,7 @@ router.post(
|
||||
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
|
||||
router.post(
|
||||
'/admin/:id/archive',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const spotlight = await spotlightService.archive(req.params.id as string);
|
||||
@@ -176,7 +177,7 @@ router.post(
|
||||
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
|
||||
router.delete(
|
||||
'/admin/:id',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
requireRole(...SOCIAL_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await spotlightService.delete(req.params.id as string);
|
||||
|
||||
@@ -13,16 +13,16 @@ import {
|
||||
} from './ticketed-events.schemas';
|
||||
import { prisma } from '../../config/database';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
||||
|
||||
/** Middleware: require admin role OR canCreateTicketedEvents permission */
|
||||
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) return next(new Error('Auth required'));
|
||||
|
||||
const userRoles = req.user.roles || [req.user.role];
|
||||
if (userRoles.some(r => ADMIN_ROLES.includes(r as UserRole))) {
|
||||
if (userRoles.some(r => EVENTS_ROLES.includes(r as UserRole))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const userRoles = req.user!.roles || [req.user!.role];
|
||||
const isAdmin = userRoles.some(r => ADMIN_ROLES.includes(r as UserRole));
|
||||
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
|
||||
|
||||
const result = await ticketedEventsService.list({
|
||||
page,
|
||||
@@ -110,7 +110,7 @@ router.post('/:id/publish', async (req: Request, res: Response, next: NextFuncti
|
||||
});
|
||||
|
||||
// POST /:id/approve (admin only)
|
||||
router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/approve', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.approve(req.params.id as string);
|
||||
res.json(event);
|
||||
@@ -118,7 +118,7 @@ router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, re
|
||||
});
|
||||
|
||||
// POST /:id/reject (admin only)
|
||||
router.post('/:id/reject', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/reject', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.reject(req.params.id as string);
|
||||
res.json(event);
|
||||
@@ -134,7 +134,7 @@ router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunctio
|
||||
});
|
||||
|
||||
// POST /:id/complete (admin only)
|
||||
router.post('/:id/complete', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.complete(req.params.id as string);
|
||||
res.json(event);
|
||||
|
||||
@@ -57,6 +57,15 @@ router.post('/start', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/upgrade/history
|
||||
* Returns the history of past upgrade results (newest first).
|
||||
*/
|
||||
router.get('/history', (_req, res) => {
|
||||
const history = upgradeService.getHistory();
|
||||
res.json({ history });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/upgrade/clear-result
|
||||
* Removes the last upgrade result file.
|
||||
|
||||
@@ -14,11 +14,14 @@ const STATUS_FILE = path.join(UPGRADE_DIR, 'status.json');
|
||||
const PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json');
|
||||
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
|
||||
const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.json');
|
||||
const HISTORY_FILE = path.join(UPGRADE_DIR, 'history.json');
|
||||
const TRIGGERED_BY_FILE = path.join(UPGRADE_DIR, 'triggered-by.txt');
|
||||
|
||||
// Stale threshold: if progress hasn't been updated in this many ms, assume crashed
|
||||
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
|
||||
interface UpgradeStatus {
|
||||
export interface UpgradeStatus {
|
||||
branch: string;
|
||||
currentCommit: string;
|
||||
currentCommitFull: string;
|
||||
@@ -37,7 +40,7 @@ interface UpgradeStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UpgradeProgress {
|
||||
export interface UpgradeProgress {
|
||||
phase: number;
|
||||
phaseName: string;
|
||||
percentage: number;
|
||||
@@ -45,7 +48,7 @@ interface UpgradeProgress {
|
||||
lastUpdate: string;
|
||||
}
|
||||
|
||||
interface UpgradeResult {
|
||||
export interface UpgradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
previousCommit: string;
|
||||
@@ -54,6 +57,7 @@ interface UpgradeResult {
|
||||
durationSeconds: number;
|
||||
warnings: string[];
|
||||
completedAt: string;
|
||||
triggeredBy?: string;
|
||||
}
|
||||
|
||||
interface TriggerPayload {
|
||||
@@ -170,6 +174,48 @@ function clearStaleProgress(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Archive a completed upgrade result to the persistent history file. */
|
||||
function archiveResult(result: UpgradeResult): void {
|
||||
try {
|
||||
const history = readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
|
||||
history.unshift(result);
|
||||
// Trim to max entries
|
||||
if (history.length > MAX_HISTORY_ENTRIES) {
|
||||
history.length = MAX_HISTORY_ENTRIES;
|
||||
}
|
||||
writeJsonFile(HISTORY_FILE, history);
|
||||
logger.info(`Archived upgrade result to history (${history.length} entries)`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to archive upgrade result:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the list of past upgrade results (newest first). */
|
||||
function getHistory(): UpgradeResult[] {
|
||||
return readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
|
||||
}
|
||||
|
||||
/** Read the triggered-by marker file (written by upgrade-watcher.sh). */
|
||||
function getTriggeredBy(): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(TRIGGERED_BY_FILE)) return null;
|
||||
return fs.readFileSync(TRIGGERED_BY_FILE, 'utf-8').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the triggered-by marker file. */
|
||||
function clearTriggeredBy(): void {
|
||||
try {
|
||||
if (fs.existsSync(TRIGGERED_BY_FILE)) {
|
||||
fs.unlinkSync(TRIGGERED_BY_FILE);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const upgradeService = {
|
||||
getStatus,
|
||||
getProgress,
|
||||
@@ -179,4 +225,8 @@ export const upgradeService = {
|
||||
triggerUpgrade,
|
||||
clearResult,
|
||||
clearStaleProgress,
|
||||
archiveResult,
|
||||
getHistory,
|
||||
getTriggeredBy,
|
||||
clearTriggeredBy,
|
||||
};
|
||||
|
||||
@@ -6,19 +6,39 @@ import { createUserSchema, updateUserSchema, listUsersSchema } from './users.sch
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { hasAnyRole, ADMIN_ROLES } from '../../utils/roles';
|
||||
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
|
||||
import { prisma } from '../../config/database';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||
|
||||
/** Check if user can manage other users (SUPER_ADMIN or canManageUsers permission) */
|
||||
function canManageUsers(user: { roles?: unknown; role?: UserRole; permissions?: Record<string, unknown> | null }): boolean {
|
||||
const roles = getUserRoles(user);
|
||||
if (roles.includes(UserRole.SUPER_ADMIN)) return true;
|
||||
return !!(user.permissions as Record<string, unknown> | null)?.canManageUsers;
|
||||
}
|
||||
|
||||
/** Middleware: require user management permission */
|
||||
function requireUserManagement(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: { message: 'Authentication required', code: 'AUTH_REQUIRED' } });
|
||||
return;
|
||||
}
|
||||
if (!canManageUsers(req.user as any)) {
|
||||
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All user routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/users — list users (admin only)
|
||||
// GET /api/users — list users (any admin)
|
||||
router.get(
|
||||
'/',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
@@ -55,10 +75,10 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/users — create user (admin only)
|
||||
// POST /api/users — create user (SUPER_ADMIN or canManageUsers)
|
||||
router.post(
|
||||
'/',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
requireUserManagement,
|
||||
validate(createUserSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -70,7 +90,7 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/users/:id — update user (admin or self, role changes admin-only)
|
||||
// PUT /api/users/:id — update user (admin or self, role changes require user management perm)
|
||||
router.put(
|
||||
'/:id',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -78,17 +98,19 @@ router.put(
|
||||
const id = req.params.id as string;
|
||||
const isAdminUser = hasAnyRole(req.user!, ADMIN_ROLES);
|
||||
const isSelf = req.user!.id === id;
|
||||
const canManage = canManageUsers(req.user as any);
|
||||
|
||||
if (!isAdminUser && !isSelf) {
|
||||
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-admins cannot change role, roles, or status
|
||||
if (!isAdminUser) {
|
||||
// Only users with management permission can change role, roles, or status
|
||||
if (!canManage) {
|
||||
delete req.body.role;
|
||||
delete req.body.roles;
|
||||
delete req.body.status;
|
||||
delete req.body.permissions;
|
||||
}
|
||||
|
||||
const parsed = updateUserSchema.parse(req.body);
|
||||
@@ -100,12 +122,10 @@ router.put(
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/users/:id/approve — approve pending user (admin only)
|
||||
const approveSchema = z.object({}).optional();
|
||||
|
||||
// POST /api/users/:id/approve — approve pending user (SUPER_ADMIN or canManageUsers)
|
||||
router.post(
|
||||
'/:id/approve',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
requireUserManagement,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
@@ -147,14 +167,14 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/users/:id/reject — reject pending user (admin only)
|
||||
// POST /api/users/:id/reject — reject pending user (SUPER_ADMIN or canManageUsers)
|
||||
const rejectSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/:id/reject',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
requireUserManagement,
|
||||
validate(rejectSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -183,7 +203,7 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/users/:id/contact — get linked Contact for a user (admin only)
|
||||
// GET /api/users/:id/contact — get linked Contact for a user (any admin)
|
||||
router.get(
|
||||
'/:id/contact',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
@@ -205,10 +225,10 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/users/:id — delete user (admin only)
|
||||
// DELETE /api/users/:id — delete user (SUPER_ADMIN or canManageUsers)
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole(...ADMIN_ROLES),
|
||||
requireUserManagement,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
|
||||
@@ -165,12 +165,17 @@ export const usersService = {
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
// Invalidate sessions when user is deactivated
|
||||
const deactivatedStatuses = ['INACTIVE', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
|
||||
// Invalidate sessions when user is deactivated or banned
|
||||
const deactivatedStatuses = ['INACTIVE', 'BANNED', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
|
||||
if (data.status && deactivatedStatuses.includes(data.status)) {
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: id } });
|
||||
}
|
||||
|
||||
// Invalidate sessions when password is changed by admin
|
||||
if (data.password) {
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: id } });
|
||||
}
|
||||
|
||||
// Fire-and-forget: sync changes to provisioned services
|
||||
userProvisioningService.onUserUpdated(toCMUser(user), data).catch(err => {
|
||||
logger.warn('User provisioning hook (update) failed:', err);
|
||||
|
||||
@@ -113,7 +113,10 @@ import { checkinRouter } from './modules/ticketed-events/checkin.routes';
|
||||
import { sseService } from './modules/social/sse.service';
|
||||
import { presenceService } from './modules/social/presence.service';
|
||||
import { upgradeService } from './modules/upgrade/upgrade.service';
|
||||
import { autoUpgradeService } from './services/auto-upgrade.service';
|
||||
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -395,6 +398,11 @@ async function start() {
|
||||
// Clean up stale upgrade progress on startup
|
||||
upgradeService.clearStaleProgress();
|
||||
|
||||
// Archive any pending upgrade result to history + send notifications
|
||||
autoUpgradeService.handlePostRestartResult().catch(() => {});
|
||||
// Start auto-upgrade scheduler if enabled
|
||||
autoUpgradeService.start().catch(() => {});
|
||||
|
||||
// Setup Rocket.Chat notification channels (non-blocking)
|
||||
rocketchatWebhookService.setupChannels().catch(() => {});
|
||||
|
||||
@@ -446,9 +454,32 @@ async function start() {
|
||||
});
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
|
||||
app.listen(env.PORT, () => {
|
||||
const server = app.listen(env.PORT, () => {
|
||||
logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`);
|
||||
});
|
||||
|
||||
// --- WebSocket upgrade handler for docs collaboration ---
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
// HocuspocusProvider connects to the base URL; document name is sent via protocol
|
||||
const collabPath = '/api/docs/collaborate';
|
||||
if (url.pathname !== collabPath && !url.pathname.startsWith(collabPath + '/')) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
// Document name may be in the URL path (y-websocket) or sent via protocol (HocuspocusProvider)
|
||||
const pathSuffix = url.pathname.slice(collabPath.length + 1); // strip /api/docs/collaborate/
|
||||
const documentName = pathSuffix ? decodeURIComponent(pathSuffix) : '';
|
||||
const token = url.searchParams.get('token') || '';
|
||||
docsCollabService.handleConnection(ws, request, { documentName, token });
|
||||
});
|
||||
});
|
||||
|
||||
// Clean stale collab states on startup + daily
|
||||
docsCollabService.cleanupStaleStates().catch(() => {});
|
||||
setInterval(() => docsCollabService.cleanupStaleStates().catch(() => {}), 24 * 60 * 60 * 1000);
|
||||
} catch (err) {
|
||||
logger.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
@@ -462,6 +493,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, async () => {
|
||||
logger.info(`${signal} received, shutting down...`);
|
||||
sseService.closeAll();
|
||||
await docsCollabService.shutdown();
|
||||
await stopProxy();
|
||||
await emailQueueService.close();
|
||||
await notificationQueueService.close();
|
||||
|
||||
257
api/src/services/auto-upgrade.service.ts
Normal file
257
api/src/services/auto-upgrade.service.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { upgradeService } from '../modules/upgrade/upgrade.service';
|
||||
import { siteSettingsService } from '../modules/settings/settings.service';
|
||||
import { emailService } from './email.service';
|
||||
import { getAdminEmailsByRole } from './notification.helper';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Auto-upgrade scheduler — checks for updates on a configurable schedule,
|
||||
* and triggers upgrades automatically when the repo is behind.
|
||||
*
|
||||
* Pattern: same setInterval-based scheduling as Listmonk sync, canvass cleanup, etc.
|
||||
*/
|
||||
|
||||
type SchedulePreset = 'daily-3am' | 'daily-4am' | 'daily-5am' | 'weekly-sun-3am' | 'weekly-mon-3am' | '12h' | '24h';
|
||||
|
||||
/** Map presets to their repeat interval in ms and initial delay calculation. */
|
||||
const SCHEDULE_CONFIG: Record<SchedulePreset, { intervalMs: number; targetHour?: number; targetDay?: number }> = {
|
||||
'daily-3am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 3 },
|
||||
'daily-4am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 4 },
|
||||
'daily-5am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 5 },
|
||||
'weekly-sun-3am': { intervalMs: 7 * 24 * 60 * 60 * 1000, targetHour: 3, targetDay: 0 },
|
||||
'weekly-mon-3am': { intervalMs: 7 * 24 * 60 * 60 * 1000, targetHour: 3, targetDay: 1 },
|
||||
'12h': { intervalMs: 12 * 60 * 60 * 1000 },
|
||||
'24h': { intervalMs: 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
const STARTUP_GRACE_MS = 5 * 60 * 1000; // 5 minutes after startup for interval presets
|
||||
const CHECK_POLL_INTERVAL_MS = 3000;
|
||||
const CHECK_POLL_TIMEOUT_MS = 60000;
|
||||
|
||||
class AutoUpgradeService {
|
||||
private initialTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private repeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Read settings and start (or restart) the scheduler if enabled. */
|
||||
async start(): Promise<void> {
|
||||
this.stop();
|
||||
|
||||
try {
|
||||
const settings = await siteSettingsService.get();
|
||||
if (!settings.enableAutoUpgrade) {
|
||||
logger.info('Auto-upgrade: disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = (settings.autoUpgradeSchedule || 'daily-3am') as SchedulePreset;
|
||||
const config = SCHEDULE_CONFIG[schedule];
|
||||
if (!config) {
|
||||
logger.warn(`Auto-upgrade: unknown schedule "${schedule}", disabling`);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialDelay = this.calculateInitialDelay(schedule, config);
|
||||
const nextRunDate = new Date(Date.now() + initialDelay);
|
||||
|
||||
logger.info(
|
||||
`Auto-upgrade: enabled (schedule=${schedule}, ` +
|
||||
`interval=${Math.round(config.intervalMs / 3600000)}h, ` +
|
||||
`next run=${nextRunDate.toLocaleString()})`,
|
||||
);
|
||||
|
||||
this.initialTimer = setTimeout(() => {
|
||||
this.runAutoUpgrade().catch(() => {});
|
||||
// Then repeat on interval
|
||||
this.repeatTimer = setInterval(
|
||||
() => this.runAutoUpgrade().catch(() => {}),
|
||||
config.intervalMs,
|
||||
);
|
||||
}, initialDelay);
|
||||
} catch (err) {
|
||||
logger.warn('Auto-upgrade: failed to start scheduler:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all timers. */
|
||||
stop(): void {
|
||||
if (this.initialTimer) {
|
||||
clearTimeout(this.initialTimer);
|
||||
this.initialTimer = null;
|
||||
}
|
||||
if (this.repeatTimer) {
|
||||
clearInterval(this.repeatTimer);
|
||||
this.repeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The scheduled task: check for updates, trigger upgrade if behind. */
|
||||
async runAutoUpgrade(): Promise<void> {
|
||||
logger.info('Auto-upgrade: starting scheduled check');
|
||||
|
||||
if (upgradeService.isRunning()) {
|
||||
logger.info('Auto-upgrade: upgrade already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Write marker so post-restart knows this was an auto-upgrade
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const markerPath = path.resolve('/app/upgrade/triggered-by.txt');
|
||||
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
||||
fs.writeFileSync(markerPath, 'auto-upgrade', 'utf-8');
|
||||
|
||||
// Trigger update check
|
||||
const statusBefore = upgradeService.getStatus();
|
||||
const checkedAtBefore = statusBefore?.checkedAt || null;
|
||||
upgradeService.triggerCheck('auto-upgrade');
|
||||
|
||||
// Poll for check completion
|
||||
const checkComplete = await this.pollForCheckComplete(checkedAtBefore);
|
||||
if (!checkComplete) {
|
||||
logger.warn('Auto-upgrade: check timed out after 60s');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = upgradeService.getStatus();
|
||||
if (!status || status.commitsBehind === 0) {
|
||||
logger.info('Auto-upgrade: already up to date');
|
||||
// Clean up marker since no upgrade needed
|
||||
upgradeService.clearTriggeredBy();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Auto-upgrade: ${status.commitsBehind} commits behind, triggering upgrade`);
|
||||
|
||||
// Read settings for pullServices option
|
||||
const settings = await siteSettingsService.get();
|
||||
upgradeService.triggerUpgrade('auto-upgrade', {
|
||||
skipBackup: false,
|
||||
pullServices: settings.autoUpgradePullServices,
|
||||
});
|
||||
// API will restart during upgrade — post-restart handler takes over
|
||||
} catch (err) {
|
||||
logger.error('Auto-upgrade: failed:', err);
|
||||
// Clean up marker on error
|
||||
upgradeService.clearTriggeredBy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on API startup — archives any pending result to history,
|
||||
* sends notification emails if this was an auto-upgrade.
|
||||
*/
|
||||
async handlePostRestartResult(): Promise<void> {
|
||||
const result = upgradeService.getResult();
|
||||
if (!result) return;
|
||||
|
||||
const triggeredBy = upgradeService.getTriggeredBy();
|
||||
|
||||
// Archive result to history (add triggeredBy info)
|
||||
const enrichedResult = { ...result, triggeredBy: triggeredBy || 'manual' };
|
||||
upgradeService.archiveResult(enrichedResult);
|
||||
logger.info(`Post-restart: archived upgrade result (triggeredBy=${triggeredBy || 'manual'}, success=${result.success})`);
|
||||
|
||||
// Send notification email if auto-upgrade and notifications enabled
|
||||
if (triggeredBy === 'auto-upgrade') {
|
||||
try {
|
||||
const settings = await siteSettingsService.get();
|
||||
if (settings.notifyAdminAutoUpgrade) {
|
||||
const emails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN]);
|
||||
if (emails.length > 0) {
|
||||
const orgName = settings.organizationName || 'Changemaker Lite';
|
||||
const statusWord = result.success ? 'completed' : 'failed';
|
||||
const subject = `[${orgName}] Auto-upgrade ${statusWord}`;
|
||||
const warningsHtml = result.warnings.length > 0
|
||||
? `<h3>Warnings</h3><ul>${result.warnings.map((w) => `<li>${w}</li>`).join('')}</ul>`
|
||||
: '';
|
||||
const html = [
|
||||
`<h2>Auto-upgrade ${statusWord}</h2>`,
|
||||
`<p>${result.message}</p>`,
|
||||
`<table style="border-collapse:collapse">`,
|
||||
`<tr><td style="padding:4px 12px 4px 0"><strong>Previous:</strong></td><td><code>${result.previousCommit}</code></td></tr>`,
|
||||
`<tr><td style="padding:4px 12px 4px 0"><strong>Current:</strong></td><td><code>${result.newCommit}</code></td></tr>`,
|
||||
`<tr><td style="padding:4px 12px 4px 0"><strong>Commits:</strong></td><td>${result.commitCount}</td></tr>`,
|
||||
`<tr><td style="padding:4px 12px 4px 0"><strong>Duration:</strong></td><td>${result.durationSeconds}s</td></tr>`,
|
||||
`<tr><td style="padding:4px 12px 4px 0"><strong>Completed:</strong></td><td>${result.completedAt}</td></tr>`,
|
||||
`</table>`,
|
||||
warningsHtml,
|
||||
`<p style="color:#888;font-size:12px">You can configure auto-upgrade settings in Settings → System.</p>`,
|
||||
].join('\n');
|
||||
|
||||
for (const email of emails) {
|
||||
await emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text: `Auto-upgrade ${statusWord}: ${result.message}\nPrevious: ${result.previousCommit}\nCurrent: ${result.newCommit}\nCommits: ${result.commitCount}\nDuration: ${result.durationSeconds}s`,
|
||||
});
|
||||
}
|
||||
logger.info(`Auto-upgrade: notification sent to ${emails.length} admin(s)`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Auto-upgrade: failed to send notification:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up result and marker files
|
||||
upgradeService.clearResult();
|
||||
upgradeService.clearTriggeredBy();
|
||||
}
|
||||
|
||||
/** Calculate ms until the next scheduled run. */
|
||||
private calculateInitialDelay(
|
||||
schedule: SchedulePreset,
|
||||
config: { intervalMs: number; targetHour?: number; targetDay?: number },
|
||||
): number {
|
||||
if (config.targetHour === undefined) {
|
||||
// Interval presets (12h, 24h) — start after grace period
|
||||
return STARTUP_GRACE_MS;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(config.targetHour, 0, 0, 0);
|
||||
|
||||
if (config.targetDay !== undefined) {
|
||||
// Weekly preset — find the next matching day
|
||||
const daysUntil = (config.targetDay - now.getDay() + 7) % 7;
|
||||
if (daysUntil === 0 && now >= target) {
|
||||
// Same day but already past — next week
|
||||
target.setDate(target.getDate() + 7);
|
||||
} else {
|
||||
target.setDate(target.getDate() + daysUntil);
|
||||
}
|
||||
} else {
|
||||
// Daily preset — if already past today's target, use tomorrow
|
||||
if (now >= target) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return target.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
/** Poll status.json until checkedAt changes (indicating check completed). */
|
||||
private pollForCheckComplete(previousCheckedAt: string | null): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
if (Date.now() - start > CHECK_POLL_TIMEOUT_MS) {
|
||||
clearInterval(interval);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const status = upgradeService.getStatus();
|
||||
if (status && status.checkedAt !== previousCheckedAt) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}
|
||||
}, CHECK_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const autoUpgradeService = new AutoUpgradeService();
|
||||
@@ -3,12 +3,41 @@ import { UserRole } from '@prisma/client';
|
||||
const ROLE_PRIORITY: Record<string, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
INFLUENCE_ADMIN: 4,
|
||||
MAP_ADMIN: 3,
|
||||
MAP_ADMIN: 4,
|
||||
BROADCAST_ADMIN: 4,
|
||||
CONTENT_ADMIN: 4,
|
||||
MEDIA_ADMIN: 4,
|
||||
PAYMENTS_ADMIN: 4,
|
||||
EVENTS_ADMIN: 4,
|
||||
SOCIAL_ADMIN: 4,
|
||||
USER: 2,
|
||||
TEMP: 1,
|
||||
};
|
||||
|
||||
export const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
/** All admin roles (any user with one of these can access /app) */
|
||||
export const ADMIN_ROLES: UserRole[] = [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.INFLUENCE_ADMIN,
|
||||
UserRole.MAP_ADMIN,
|
||||
UserRole.BROADCAST_ADMIN,
|
||||
UserRole.CONTENT_ADMIN,
|
||||
UserRole.MEDIA_ADMIN,
|
||||
UserRole.PAYMENTS_ADMIN,
|
||||
UserRole.EVENTS_ADMIN,
|
||||
UserRole.SOCIAL_ADMIN,
|
||||
];
|
||||
|
||||
// Module-specific role groups
|
||||
export const INFLUENCE_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
|
||||
export const MAP_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||
export const BROADCAST_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.BROADCAST_ADMIN];
|
||||
export const CONTENT_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.CONTENT_ADMIN];
|
||||
export const MEDIA_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MEDIA_ADMIN];
|
||||
export const PAYMENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.PAYMENTS_ADMIN];
|
||||
export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_ADMIN];
|
||||
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
|
||||
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
|
||||
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
|
||||
|
||||
/** Check if the user has any of the specified roles */
|
||||
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
|
||||
|
||||
Reference in New Issue
Block a user