@design-edito/cli 0.0.78 → 0.0.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/cli/assets/list.txt +1 -2
  2. package/cli/assets/version.txt +1 -1
  3. package/make-template/assets/express/esbuild.config.js +19 -0
  4. package/make-template/assets/express/package.json +16 -12
  5. package/make-template/assets/express/src/index.ts +13 -49
  6. package/make-template/assets/express/src/public/index.css +7 -0
  7. package/make-template/assets/express/src/public/index.html +15 -0
  8. package/make-template/assets/express/src/public/index.js +31 -0
  9. package/make-template/assets/express/src/tsconfig.json +7 -3
  10. package/make-template/assets/express/src/www/index.ts +42 -0
  11. package/make-template/assets/express-api/Dockerfile +18 -0
  12. package/make-template/assets/express-api/Dockerfile.dev +8 -0
  13. package/make-template/assets/express-api/env +36 -0
  14. package/make-template/assets/express-api/esbuild.config.js +26 -0
  15. package/make-template/assets/express-api/gitignore +214 -0
  16. package/make-template/assets/express-api/package.json +60 -0
  17. package/make-template/assets/express-api/src/api/auth/_utils/index.ts +47 -0
  18. package/make-template/assets/express-api/src/api/auth/index.ts +25 -0
  19. package/make-template/assets/express-api/src/api/auth/login/index.ts +101 -0
  20. package/make-template/assets/express-api/src/api/auth/logout/index.ts +45 -0
  21. package/make-template/assets/express-api/src/api/auth/refresh-token/index.ts +54 -0
  22. package/make-template/assets/express-api/src/api/auth/request-email-verification-token/index.ts +45 -0
  23. package/make-template/assets/express-api/src/api/auth/request-new-password/index.ts +62 -0
  24. package/make-template/assets/express-api/src/api/auth/signup/index.ts +99 -0
  25. package/make-template/assets/express-api/src/api/auth/submit-new-password/index.ts +61 -0
  26. package/make-template/assets/express-api/src/api/auth/verify-email/index.ts +79 -0
  27. package/make-template/assets/express-api/src/api/auth/whoami/index.ts +68 -0
  28. package/make-template/assets/express-api/src/api/index.ts +18 -0
  29. package/make-template/assets/express-api/src/api/types.ts +76 -0
  30. package/make-template/assets/express-api/src/api/utils.ts +146 -0
  31. package/make-template/assets/express-api/src/database/index.ts +173 -0
  32. package/make-template/assets/express-api/src/email/index.ts +47 -0
  33. package/make-template/assets/express-api/src/env/index.ts +77 -0
  34. package/make-template/assets/express-api/src/errors/index.ts +128 -0
  35. package/make-template/assets/express-api/src/index.ts +42 -0
  36. package/make-template/assets/express-api/src/init/index.ts +156 -0
  37. package/make-template/assets/express-api/src/jwt/index.ts +105 -0
  38. package/make-template/assets/express-api/src/public/index.css +7 -0
  39. package/make-template/assets/express-api/src/public/index.html +15 -0
  40. package/make-template/assets/express-api/src/public/index.js +31 -0
  41. package/make-template/assets/express-api/src/schema/_history/index.ts +124 -0
  42. package/make-template/assets/express-api/src/schema/_meta/index.ts +113 -0
  43. package/make-template/assets/express-api/src/schema/index.ts +17 -0
  44. package/make-template/assets/express-api/src/schema/user/index.ts +117 -0
  45. package/make-template/assets/express-api/src/schema/user-email-validation-token/index.ts +20 -0
  46. package/make-template/assets/express-api/src/schema/user-password-renewal-token/index.ts +20 -0
  47. package/make-template/assets/express-api/src/schema/user-revoked-auth-token/index.ts +26 -0
  48. package/make-template/assets/express-api/src/tsconfig.json +16 -0
  49. package/make-template/assets/express-api/src/www/index.ts +43 -0
  50. package/make-template/index.js +2 -3
  51. package/make-template/index.js.map +3 -3
  52. package/package.json +7 -8
  53. package/make-template/assets/express/src/routes/index.ts +0 -7
  54. package/upgrade/index.js +0 -12
  55. package/upgrade/index.js.map +0 -7
@@ -0,0 +1,128 @@
1
+ import {
2
+ FilterQuery as MongooseFilterQuery,
3
+ Model as MongooseModel
4
+ } from 'mongoose'
5
+ import { Errors } from '@design-edito/tools/agnostic/errors'
6
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
7
+
8
+ export enum Codes {
9
+ // UNKNOWN
10
+ UNKNOWN_ERROR = 'unknown-error',
11
+
12
+ // AUTH
13
+ USER_NOT_AUTHENTICATED = 'user-not-authenticated',
14
+ USER_NOT_AUTHORIZED = 'user-not-authorized',
15
+ USER_DOES_NOT_EXIST = 'user-does-not-exist',
16
+ USER_EMAIL_DOES_NOT_EXIST = 'user-email-does-not-exist',
17
+ USERNAME_ALREADY_TAKEN = 'username-already-taken',
18
+ EMAIL_ADDRESS_ALREADY_TAKEN = 'email-address-already-taken',
19
+ USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED = 'user-email-verification-token-not-provided',
20
+ USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST = 'user-email-verification-token-does-not-exist',
21
+ USER_EMAIL_VERIFICATION_PROCESS_FAILED = 'user-email-verification-process-failed',
22
+ USER_EMAIL_ALREADY_VERIFIED = 'user-email-already-verified',
23
+ INVALID_CREDENTIALS = 'invalid-credentials',
24
+ USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST = 'user-password-renewal-token-does-not-exist',
25
+
26
+ // REQUESTS
27
+ INVALID_REQUEST_BODY = 'invalid-request-body',
28
+
29
+ // DATABASE
30
+ DB_ERROR = 'db-error',
31
+ DB_NO_DOCUMENT_MATCHES_FILTER = 'db-no-document-matches-filter'
32
+ }
33
+
34
+ export const source = Errors.Register.makeSource({
35
+ [Codes.UNKNOWN_ERROR]: {
36
+ message: 'An unknown error occured',
37
+ detailsMaker: (details?: string) => details
38
+ },
39
+
40
+ [Codes.USER_NOT_AUTHENTICATED]: {
41
+ message: 'User must be authenticated.',
42
+ detailsMaker: (details?: string) => details
43
+ },
44
+
45
+ [Codes.USER_NOT_AUTHORIZED]: {
46
+ message: 'User has not sufficient permissions',
47
+ detailsMaker: (details?: string) => details
48
+ },
49
+
50
+ [Codes.USER_DOES_NOT_EXIST]: {
51
+ message: 'Impossible to retreive user information',
52
+ detailsMaker: (userId: string) => ({ userId })
53
+ },
54
+
55
+ [Codes.USER_EMAIL_DOES_NOT_EXIST]: {
56
+ message: 'This email is not tied to any user account.',
57
+ detailsMaker: (email: string) => ({ email })
58
+ },
59
+
60
+ [Codes.USERNAME_ALREADY_TAKEN]: {
61
+ message: 'This username is already taken',
62
+ detailsMaker: (username: string) => ({ username })
63
+ },
64
+
65
+ [Codes.EMAIL_ADDRESS_ALREADY_TAKEN]: {
66
+ message: 'This email address is already taken',
67
+ detailsMaker: (email: string) => ({ email })
68
+ },
69
+
70
+ [Codes.USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED]: {
71
+ message: 'This endpoint expects a token in the URL to process the validation',
72
+ detailsMaker: () => undefined
73
+ },
74
+
75
+ [Codes.USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST]: {
76
+ message: 'The email verification token provided does not exist',
77
+ detailsMaker: () => undefined
78
+ },
79
+
80
+ [Codes.USER_EMAIL_VERIFICATION_PROCESS_FAILED]: {
81
+ message: 'Something went wrong while updating the user verification status',
82
+ detailsMaker: (details: string) => details
83
+ },
84
+
85
+ [Codes.USER_EMAIL_ALREADY_VERIFIED]: {
86
+ message: 'This user already verified their email',
87
+ detailsMaker: (email: string) => ({ email })
88
+ },
89
+
90
+ [Codes.INVALID_CREDENTIALS]: {
91
+ message: 'The provided credentials are invalid',
92
+ detailsMaker: () => undefined
93
+ },
94
+
95
+ [Codes.USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST]: {
96
+ message: 'The password renewal token provided does not exist',
97
+ detailsMaker: (email: string, token: string) => ({ email, token })
98
+ },
99
+
100
+ [Codes.INVALID_REQUEST_BODY]: {
101
+ message: 'The request body provided could not be used for the operation',
102
+ detailsMaker: ((body: any, error: string) => ({ body, error }))
103
+ },
104
+
105
+ [Codes.DB_ERROR]: {
106
+ message: 'The database returned an error',
107
+ detailsMaker: ((dbError: string) => ({ dbError }))
108
+ },
109
+
110
+ [Codes.DB_NO_DOCUMENT_MATCHES_FILTER]: {
111
+ message: 'No document matches the provided filter',
112
+ detailsMaker: <M extends MongooseModel<any>>(
113
+ model: M,
114
+ filter: MongooseFilterQuery<M extends MongooseModel<infer DocType> ? DocType : never>
115
+ ) => ({ collection: model.name, filter })
116
+ }
117
+ } as const)
118
+
119
+ export const register = Errors.Register.from(source)
120
+
121
+ export type ErrorData<Code extends Codes> = Errors.Register.ErrorData<typeof source, Code>
122
+
123
+ export function makeFailureOutcome<Code extends Codes> (
124
+ code: Code,
125
+ ...params: Errors.Register.DetailsMakerParams<typeof register.source, Code>) {
126
+ const errData = register.getErrorData(code, ...params)
127
+ return Outcome.makeFailure(errData)
128
+ }
@@ -0,0 +1,42 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import cookieParser from 'cookie-parser'
4
+ import cors from 'cors'
5
+ import express from 'express'
6
+ import logger from 'morgan'
7
+ import * as Api from './api'
8
+ import { makeRouter } from './api/utils'
9
+ import * as Jwt from './jwt'
10
+ import * as Database from'./database'
11
+ import * as Init from './init'
12
+ import { serve } from './www'
13
+
14
+ // Prepare to gracefully shutdon the server if needed
15
+ Init.captureTerminationSignals()
16
+
17
+ // App setup
18
+ const app = express()
19
+ app.use(cors()) // [WIP] configure cors and allowed origins
20
+ app.use(logger('dev')) // [WIP] configure logger for prod an all
21
+ app.use(express.json())
22
+ app.use(express.urlencoded({ extended: false }))
23
+ app.use(cookieParser())
24
+
25
+ // Static files
26
+ const ROOT = path.dirname(fileURLToPath(import.meta.url))
27
+ const PUBLIC = path.join(ROOT, 'public')
28
+ app.use(express.static(PUBLIC))
29
+
30
+ // Authentication
31
+ app.use(Jwt.authenticate)
32
+
33
+ // API endpoints
34
+ app.use('/', makeRouter(Api.Endpoints))
35
+
36
+ // Database connection
37
+ await Database.connect()
38
+ await Init.ensureRootUser()
39
+ await Init.scheduleCronTasks()
40
+
41
+ // Start server
42
+ serve(app)
@@ -0,0 +1,156 @@
1
+ import Agenda from 'agenda'
2
+ import { Types as MongooseTypes } from 'mongoose'
3
+ import { Logs } from '@design-edito/tools/agnostic/misc/logs'
4
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
5
+ import { Duration } from '@design-edito/tools/agnostic/time/duration'
6
+ import * as Database from'../database'
7
+ import {
8
+ ROOT_USER_ID,
9
+ ROOT_USER_PWD,
10
+ ROOT_USER_EMAIL,
11
+ ROOT_USER_NAME,
12
+ DB_RESERVED_AGENDA_JOBS_COLLECTION_NAME,
13
+ REFRESH_TOKEN_EXPIRATION_SECONDS
14
+ } from '../env'
15
+ import * as User from '../schema/user'
16
+ import { UserEmailValidationTokenModel } from '../schema/user-email-validation-token'
17
+ import { UserPasswordRenewalTokenModel } from '../schema/user-password-renewal-token'
18
+ import { UserRevokedTokenModel } from '../schema/user-revoked-auth-token'
19
+
20
+ export async function ensureRootUser () {
21
+ console.log(Logs.styles.info('Ensuring ROOT user...'))
22
+ try {
23
+ // [WIP] use Database helpers here ?
24
+ const rootUsersViaRole = await User.LocalUserModel.find({ role: User.Role.ROOT }).exec()
25
+ const rootUsersViaId = await User.LocalUserModel.find({ _id: ROOT_USER_ID }).exec()
26
+ const rootUsersWithDuplicates = [...rootUsersViaRole, ...rootUsersViaId]
27
+ const rootUsersIdsSet = new Set(rootUsersWithDuplicates.map(usr => usr._id.toString()))
28
+
29
+ // No ROOT user found
30
+ if (rootUsersIdsSet.size === 0) {
31
+ const newRootUser = new User.LocalUserModel({
32
+ _id: new MongooseTypes.ObjectId(ROOT_USER_ID),
33
+ username: ROOT_USER_NAME,
34
+ email: ROOT_USER_EMAIL,
35
+ password: ROOT_USER_PWD,
36
+ role: User.Role.ROOT,
37
+ status: User.Status.ACTIVE,
38
+ verified: true
39
+ })
40
+ const rootUserInserted = await Database.insertOne<User.ILocalUser>(
41
+ User.LocalUserModel,
42
+ newRootUser,
43
+ { initiatorId: ROOT_USER_ID }
44
+ )
45
+ if (!rootUserInserted.success) {
46
+ console.log(Logs.styles.danger('Something went wrong while creating ROOT user. Shutting down'))
47
+ const { message, details } = rootUserInserted.error
48
+ console.log(Logs.styles.error(message))
49
+ console.log(Logs.styles.error(details.dbError))
50
+ return await shutdown(1)
51
+ }
52
+ console.log(Logs.styles.important('ROOT user created'))
53
+ return
54
+ }
55
+
56
+ // Many ROOT users found
57
+ if (rootUsersIdsSet.size > 1) {
58
+ console.log(Logs.styles.danger('Multiple ROOT users found, shutting down'))
59
+ return await shutdown(1)
60
+ }
61
+
62
+ // Single ROOT user found
63
+ console.log(Logs.styles.regular('ROOT user exists'))
64
+
65
+ } catch (err) {
66
+ console.log(Logs.styles.danger('An unknown error occured while initing the database'))
67
+ console.log(Logs.styles.error(unknownToString(err)))
68
+ return await shutdown(1)
69
+ }
70
+ }
71
+
72
+ let agenda: Agenda | null = null
73
+ async function startAgenda (): Promise<void> {
74
+ if (agenda !== null) return;
75
+ agenda = new Agenda()
76
+ agenda.database(Database.connectionString, DB_RESERVED_AGENDA_JOBS_COLLECTION_NAME)
77
+ agenda.processEvery('1 minute')
78
+ await new Promise<void>((resolve, reject) => {
79
+ agenda!.once('ready', resolve)
80
+ agenda!.once('error', reject)
81
+ })
82
+ await agenda.start()
83
+ }
84
+
85
+ async function scheduleCronTask (name: string, interval: string, worker: () => void | Promise<void>) {
86
+ try {
87
+ await startAgenda()
88
+ agenda!.define(name, worker)
89
+ await agenda!.every(interval, name)
90
+ } catch (err) {
91
+ console.log(Logs.styles.danger('An unknown error occured while initing the cron tasks'))
92
+ console.log(Logs.styles.error(unknownToString(err)))
93
+ return await shutdown(1)
94
+ }
95
+ }
96
+
97
+ export async function scheduleCronTasks () {
98
+ await scheduleCronTask('ENSURE_ROOT_USER', '1 minute', () => ensureRootUser())
99
+ await scheduleCronTask('CLEANUP_USER_EMAIL_VERIFICATION_TOKENS', '5 minutes', async () => {
100
+ const now = new Date()
101
+ const deleted = await Database.deleteMany(
102
+ UserEmailValidationTokenModel,
103
+ { expiresOn: { $lt: now } },
104
+ { initiatorId: ROOT_USER_ID }
105
+ )
106
+ if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
107
+ return
108
+ })
109
+ await scheduleCronTask('CLEANUP_USER_REVOKED_AUTH_TOKENS', '5 minutes', async () => {
110
+ const now = Date.now()
111
+ const refreshTokenLifetimeSeconds = REFRESH_TOKEN_EXPIRATION_SECONDS
112
+ const refreshTokenLifetimeMs = Duration.seconds(refreshTokenLifetimeSeconds).toMilliseconds()
113
+ const oldEnoughRevokedTokensThreshold = new Date(now - (refreshTokenLifetimeMs * 5))
114
+ const deleted = await Database.deleteMany(
115
+ UserRevokedTokenModel,
116
+ { revokedOn: { $lt: oldEnoughRevokedTokensThreshold } },
117
+ { initiatorId: ROOT_USER_ID }
118
+ )
119
+ if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
120
+ return
121
+ })
122
+ await scheduleCronTask('CLEANUP_USER_PASSWORD_RENEWAL_TOKENS', '5 minutes', async () => {
123
+ const now = new Date()
124
+ const deleted = await Database.deleteMany(
125
+ UserPasswordRenewalTokenModel,
126
+ { expiresOn: { $lt: now } },
127
+ { initiatorId: ROOT_USER_ID }
128
+ )
129
+ if (!deleted.success) return; // [WIP] what to do with the error ? Send mail ?
130
+ return
131
+ })
132
+ }
133
+
134
+ export function captureTerminationSignals () {
135
+ process.on('SIGINT', async () => {
136
+ console.log('SIGINT received.')
137
+ await shutdown(0)
138
+ })
139
+
140
+ process.on('SIGTERM', async () => {
141
+ console.log('SIGTERM received.')
142
+ await shutdown(0)
143
+ })
144
+ }
145
+
146
+ export async function gracefulShutdown () {
147
+ if (agenda !== null) await agenda.stop()
148
+ await Database.disconnect()
149
+ }
150
+
151
+ export async function shutdown (code: number) {
152
+ console.log('Running cleanup tasks...')
153
+ await gracefulShutdown()
154
+ console.log('Cleanup complete. Exiting...')
155
+ process.exit(code)
156
+ }
@@ -0,0 +1,105 @@
1
+ import { Request, Response, NextFunction } from 'express'
2
+ import jwt from 'jsonwebtoken'
3
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
4
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
5
+ import * as Database from '../database'
6
+ import {
7
+ MODE,
8
+ JWT_SECRET,
9
+ ACCESS_TOKEN_EXPIRATION_SECONDS,
10
+ REFRESH_TOKEN_EXPIRATION_SECONDS,
11
+ ACCESS_TOKEN_RENEWAL_THRESHOLD_SECONDS,
12
+ ACCESS_TOKEN_MAX_REFRESH_COUNT,
13
+ ROOT_USER_ID
14
+ } from '../env'
15
+ import { BaseUserModel } from '../schema/user'
16
+ import { UserRevokedTokenModel } from '../schema/user-revoked-auth-token'
17
+
18
+ declare global {
19
+ namespace Express {
20
+ interface Locals {
21
+ accessTokenSigned?: string
22
+ accessTokenPayload?: Payload
23
+ refreshTokenSigned?: string
24
+ refreshTokenPayload?: Payload
25
+ }
26
+ }
27
+ }
28
+
29
+ type Payload = {
30
+ userId: string
31
+ exp: number
32
+ refreshCount: number
33
+ }
34
+
35
+ export function isValidPayload (payload: unknown): payload is Payload {
36
+ if (!isNonNullObject(payload)) return false
37
+ if (!('userId' in payload)) return false
38
+ if (typeof payload.userId !== 'string') return false
39
+ if (!('exp' in payload)) return false
40
+ if (typeof payload.exp !== 'number') return false
41
+ if (!('refreshCount' in payload)) return false
42
+ if (typeof payload.refreshCount !== 'number') return false
43
+ return true
44
+ }
45
+
46
+ export async function generateAccessToken (userId: string, refreshCount: number) {
47
+ const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
48
+ if (!foundUser.success) return foundUser
49
+ const payload: Omit<Payload, 'exp'> = { userId, refreshCount }
50
+ const options: jwt.SignOptions = { expiresIn: ACCESS_TOKEN_EXPIRATION_SECONDS }
51
+ return Outcome.makeSuccess(jwt.sign(payload, JWT_SECRET, options))
52
+ }
53
+
54
+ export async function generateRefreshToken (userId: string, refreshCount: number) {
55
+ const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
56
+ if (!foundUser.success) return foundUser
57
+ const payload: Omit<Payload, 'exp'> = { userId, refreshCount }
58
+ const options: jwt.SignOptions = { expiresIn: REFRESH_TOKEN_EXPIRATION_SECONDS }
59
+ return Outcome.makeSuccess(jwt.sign(payload, JWT_SECRET, options))
60
+ }
61
+
62
+ export async function authenticate (req: Request, res: Response, next: NextFunction) {
63
+ const authHeader = req.headers.authorization
64
+ if (authHeader === undefined || !authHeader.startsWith('Bearer ')) return next()
65
+ const token = authHeader.split(' ')[1]
66
+ if (token === undefined) return next()
67
+ try {
68
+ const decoded = jwt.verify(token, JWT_SECRET)
69
+ const validated = isValidPayload(decoded)
70
+ if (!validated) return next()
71
+ const now = Math.floor(Date.now() / 1000)
72
+ const { userId, exp, refreshCount } = decoded
73
+ const accessTokenAlmostStale = exp - now < ACCESS_TOKEN_RENEWAL_THRESHOLD_SECONDS
74
+ const accessTokenCanRenew = refreshCount < ACCESS_TOKEN_MAX_REFRESH_COUNT
75
+ const accessTokenShouldRenew = accessTokenAlmostStale && accessTokenCanRenew
76
+ if (accessTokenShouldRenew) {
77
+ const foundRevokedToken = await Database.findOne(
78
+ UserRevokedTokenModel,
79
+ { value: token },
80
+ { initiatorId: ROOT_USER_ID }
81
+ )
82
+ if (foundRevokedToken.success) return next()
83
+ const newToken = generateAccessToken(userId, refreshCount + 1)
84
+ res.setHeader('Authorization', `Bearer ${newToken}`)
85
+ }
86
+ res.locals.accessTokenSigned = token
87
+ res.locals.accessTokenPayload = decoded
88
+ return next()
89
+ } catch (err) {
90
+ // [WIP] Maybe log something ?
91
+ return next()
92
+ }
93
+ }
94
+
95
+ export function attachAccessTokenToRes (res: Response, token: string) {
96
+ res.setHeader('Authorization', `Bearer ${token}`)
97
+ }
98
+
99
+ export function attachRefreshTokenToRes (res: Response, token: string) {
100
+ res.cookie('refreshToken', token, {
101
+ httpOnly: true,
102
+ secure: MODE === 'production',
103
+ sameSite: 'strict'
104
+ })
105
+ }
@@ -0,0 +1,7 @@
1
+ html {
2
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
3
+ }
4
+
5
+ button {
6
+ cursor: pointer;
7
+ }
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <script type="module" src="./index.js"></script>
7
+ <link rel="stylesheet" href="./index.css">
8
+ </head>
9
+ <body>
10
+ <h1>LM-Publisher</h1>
11
+
12
+ <button class="create-random-project">Create random project</button>
13
+
14
+ </body>
15
+ </html>
@@ -0,0 +1,31 @@
1
+ const $responses = document.querySelector('.requests')
2
+ const $createRandomProject = document.querySelector('.create-random-project')
3
+
4
+ async function fetchAndLog (url, method, body) {
5
+ const id = Math.random().toString(36).slice(2)
6
+ console.log('%cSending request...', 'font-weight: 600')
7
+ console.log('id:', id)
8
+ console.log(`${method}:`, url)
9
+ const response = await window.fetch(url, {
10
+ method,
11
+ body: JSON.stringify(body),
12
+ headers: { 'Content-Type': 'application/json' }
13
+ })
14
+ const json = await response.json()
15
+ console.log('%cReceived response', 'font-weight: 600')
16
+ console.log('id:', id)
17
+ console.log(`${method}:`, url)
18
+ console.log('body:', body)
19
+ console.log('response:', response)
20
+ console.log('json:', json)
21
+ }
22
+
23
+ $createRandomProject.addEventListener('click', () => {
24
+ fetchAndLog('http://localhost:3000/projects/create', 'POST', {
25
+ name: 'Mon premier projet :\')',
26
+ publicationTargetDate: Date.now(),
27
+ sourcesIds: [],
28
+ articlesIds: [],
29
+ assetsIds: [],
30
+ })
31
+ })
@@ -0,0 +1,124 @@
1
+ import Zlib from 'node:zlib'
2
+ import {
3
+ Types as MongooseTypes,
4
+ Schema as MongooseSchema,
5
+ Document as MongooseDocument,
6
+ CallbackWithoutResultAndOptionalError as MongooseCallbackWithoutResultAndOptionalError
7
+ } from 'mongoose'
8
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
9
+ import { DocumentWithLocals, QueryWithLocals } from '../../database'
10
+
11
+ // Document
12
+ export type IHistoryItem = {
13
+ updationTime: Date
14
+ updaterId: MongooseTypes.ObjectId,
15
+ stringifiedDocument: string
16
+ }
17
+ export type IHistory = Array<IHistoryItem>
18
+
19
+ export type WithHistory<T> = T & { _history: IHistory }
20
+ export type WithoutHistory<T> = Omit<T, '_history'> & { _history?: undefined }
21
+
22
+ // Schema
23
+ export const HistoryItemSchema = new MongooseSchema<IHistoryItem>({
24
+ updationTime: { type: Date, required: true },
25
+ updaterId: { type: MongooseSchema.ObjectId, required: true },
26
+ stringifiedDocument: { type: String, required: true }
27
+ })
28
+
29
+ export async function makeHistoryItem (document: MongooseDocument, initiatorObjectId: MongooseTypes.ObjectId): Promise<IHistoryItem> {
30
+ const docAsObject = document.toObject()
31
+ const coreEntries = Object.entries(docAsObject).filter(([key]) => !key.startsWith('_'))
32
+ const strippedToCore = Object.fromEntries(coreEntries)
33
+ const stringified = JSON.stringify(strippedToCore)
34
+ const compressed = await new Promise<Buffer>((resolve, reject) => {
35
+ Zlib.gzip(stringified, (err, compressed) => {
36
+ if (err !== null) return reject(err)
37
+ return resolve(compressed)
38
+ })
39
+ })
40
+ const base64 = compressed.toString('base64')
41
+ const historyItem: IHistoryItem = {
42
+ updationTime: new Date(),
43
+ updaterId: initiatorObjectId,
44
+ stringifiedDocument: base64
45
+ }
46
+ return historyItem
47
+ }
48
+
49
+ export function withHistory<T extends Object> (inputSchema: MongooseSchema<T>): MongooseSchema<WithHistory<T>> {
50
+ const schema = inputSchema.clone() as MongooseSchema<WithHistory<T>>
51
+ schema.add(new MongooseSchema<WithHistory<{}>>({
52
+ _history: {
53
+ type: [HistoryItemSchema],
54
+ required: true,
55
+ default: []
56
+ }
57
+ }))
58
+ schema.pre('save', handleSave)
59
+ schema.pre('insertMany', handleInsertMany)
60
+ schema.pre('updateOne', handleUpdate)
61
+ schema.pre('findOneAndUpdate', handleUpdate)
62
+
63
+ async function handleSave (
64
+ this: WithHistory<DocumentWithLocals<{}>>,
65
+ next: MongooseCallbackWithoutResultAndOptionalError
66
+ ) {
67
+ const context = this.$locals?.context
68
+ const initiatorId = context?.initiatorId ?? null
69
+ const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null
70
+ if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for save operation.'))
71
+ try {
72
+ const historyItem = await makeHistoryItem(this, initiatorObjectId)
73
+ if (this.isNew) { this._history = [historyItem] }
74
+ else this._history.push(historyItem)
75
+ next()
76
+ } catch (err) {
77
+ const errStr = unknownToString(err)
78
+ next(new Error(errStr))
79
+ }
80
+ }
81
+
82
+ async function handleInsertMany (
83
+ next: MongooseCallbackWithoutResultAndOptionalError,
84
+ docs: WithHistory<DocumentWithLocals<{}>>[]
85
+ ) {
86
+ try {
87
+ for (const doc of docs) {
88
+ const context = doc.$locals?.context
89
+ const initiatorId = context?.initiatorId ?? null
90
+ const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null
91
+ if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for insertMany operation.'))
92
+ const historyItem = await makeHistoryItem(doc, initiatorObjectId)
93
+ doc._history = [historyItem]
94
+ }
95
+ next()
96
+ } catch (err) {
97
+ const errStr = unknownToString(err)
98
+ next(new Error(errStr))
99
+ }
100
+ }
101
+
102
+ async function handleUpdate (
103
+ this: QueryWithLocals<WithHistory<{}>>,
104
+ next: MongooseCallbackWithoutResultAndOptionalError
105
+ ) {
106
+ const context = this.getOptions().$locals?.context;
107
+ const initiatorId = context?.initiatorId ?? null;
108
+ const initiatorObjectId = initiatorId !== null ? new MongooseTypes.ObjectId(initiatorId) : null;
109
+ if (initiatorObjectId === null) return next(new Error('initiatorId is required in context for update operation.'))
110
+ try {
111
+ const docPromise = this.model.findOne(this.getFilter()).exec() as Promise<WithHistory<MongooseDocument> | null>
112
+ const doc = await docPromise
113
+ if (doc === null) return next(new Error('Document not found for update.'))
114
+ const historyItem = await makeHistoryItem(doc, initiatorObjectId)
115
+ doc._history.push(historyItem)
116
+ this.set({ _history: doc._history })
117
+ next()
118
+ } catch (err) {
119
+ const errStr = unknownToString(err)
120
+ next(new Error(errStr))
121
+ }
122
+ }
123
+ return schema
124
+ }