• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inclusion-numerique / coop-mediation-numerique / 39cc4768-bcb1-433f-a4a6-d76e5937751a

01 Apr 2026 04:06PM UTC coverage: 7.472% (+0.5%) from 6.94%
39cc4768-bcb1-433f-a4a6-d76e5937751a

push

circleci

web-flow
Merge pull request #469 from inclusion-numerique/dev

MEP 2026-01-01

500 of 10542 branches covered (4.74%)

Branch coverage included in aggregate %.

145 of 414 new or added lines in 38 files covered. (35.02%)

13 existing lines in 10 files now uncovered.

1500 of 16224 relevant lines covered (9.25%)

36.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/packages/cdk/src/WebAppStack.ts
1
import { createJobExecutionCron } from '@app/cdk/createJobExecutionCron'
2
import { environmentVariablesFromList } from '@app/cdk/environmentVariable'
3
import { WebCdkOutput } from '@app/cdk/getCdkOutput'
4
import { createOutput } from '@app/cdk/output'
5
import { terraformBackend } from '@app/cdk/terraformBackend'
6
import {
7
  computeBranchNamespace,
8
  createPreviewSubdomain,
9
  namespacer,
10
} from '@app/cdk/utils'
11
import {
12
  containerNamespaceName,
13
  databaseInstanceName,
14
  mainDomain,
15
  mainRootDomain,
16
  mainSubdomain,
17
  previewDomain,
18
  previewRootDomain,
19
  previewSubdomain,
20
  projectSlug,
21
  projectTitle,
22
  region,
23
  smtpPort,
24
} from '@app/config/config'
25
import { Container } from '@app/scaleway/container'
26
import { ContainerDomain } from '@app/scaleway/container-domain'
27
import { DataScalewayContainerNamespace } from '@app/scaleway/data-scaleway-container-namespace'
28
import { DataScalewayDomainZone } from '@app/scaleway/data-scaleway-domain-zone'
29
import { DataScalewayRdbInstance } from '@app/scaleway/data-scaleway-rdb-instance'
30
import { DomainRecord, DomainRecordConfig } from '@app/scaleway/domain-record'
31
import { ObjectBucket } from '@app/scaleway/object-bucket'
32
import { ScalewayProvider } from '@app/scaleway/provider'
33
import { RdbDatabase } from '@app/scaleway/rdb-database'
34
import { RdbPrivilege } from '@app/scaleway/rdb-privilege'
35
import { RdbUser } from '@app/scaleway/rdb-user'
36
import { Fn, TerraformStack } from 'cdktf'
37
import { Construct } from 'constructs'
38

39
export const webAppStackVariables = [
×
40
  'BREVO_USERS_LIST_ID',
41
  'SCW_DEFAULT_ORGANIZATION_ID',
42
  'SCW_PROJECT_ID',
43
  'SCALEWAY_GENERATIVE_API_SERVICE_URL',
44
  'ALBERT_SERVICE_URL',
45
  'WEB_CONTAINER_IMAGE',
46
] as const
47
export const webAppStackSensitiveVariables = [
×
48
  'API_ENTREPRISE_TOKEN',
49
  'BREVO_API_KEY',
50
  'SCW_ACCESS_KEY',
51
  'SCW_SECRET_KEY',
52
  'DATABASE_PASSWORD',
53
  'PROCONNECT_PREVIEW_CLIENT_SECRET',
54
  'PROCONNECT_MAIN_CLIENT_SECRET',
55
  'INTERNAL_API_PRIVATE_KEY',
56
  'CONSEILLER_NUMERIQUE_MONGODB_URL',
57
  'HMAC_SECRET_KEY',
58
  'ALBERT_API_KEY',
59
  'BRAVE_API_KEY',
60
  'RDV_SERVICE_PUBLIC_PREVIEW_API_KEY',
61
  'RDV_SERVICE_PUBLIC_PREVIEW_OAUTH_CLIENT_ID',
62
  'RDV_SERVICE_PUBLIC_PREVIEW_OAUTH_CLIENT_SECRET',
63
  'RDV_API_KEY',
64
  'RDV_SERVICE_PUBLIC_MAIN_OAUTH_CLIENT_ID',
65
  'RDV_SERVICE_PUBLIC_MAIN_OAUTH_CLIENT_SECRET',
66
  'RDV_SERVICE_PUBLIC_WEBHOOK_SECRET',
67
  'SMTP_PASSWORD',
68
  'SMTP_SERVER',
69
  'SMTP_USERNAME',
70
  'SMTP_MAILDEV_USERNAME',
71
  'SMTP_MAILDEV_PASSWORD',
72
  'DATASPACE_API_KEY',
73
] as const
74

75
/**
76
 * This stack represents the web app for a given branch (namespace).
77
 * It can be deployed for each branch.
78
 */
79
export class WebAppStack extends TerraformStack {
80
  constructor(scope: Construct, branch: string) {
81
    super(scope, 'web')
×
82

83
    const namespace = computeBranchNamespace(branch)
×
84

85
    const namespaced = namespacer(namespace)
×
86

87
    // ⚠️ When calling this function, do not forget to update typings in src/getCdkOutput.ts
88
    const output = createOutput<WebCdkOutput>(this)
×
89

90
    const isMain = namespace === 'main'
×
91

92
    const { hostname, subdomain } = isMain
×
93
      ? { hostname: mainDomain, subdomain: '' }
94
      : createPreviewSubdomain(namespace, previewDomain)
95

96
    const environmentVariables = environmentVariablesFromList(
×
97
      this,
98
      webAppStackVariables,
99
      { sensitive: false },
100
    )
101
    const sensitiveEnvironmentVariables = environmentVariablesFromList(
×
102
      this,
103
      webAppStackSensitiveVariables,
104
      { sensitive: true },
105
    )
106

107
    // Configuring provider that will be used for the rest of the stack
108
    new ScalewayProvider(this, 'provider', {
×
109
      region,
110
      accessKey: sensitiveEnvironmentVariables.SCW_ACCESS_KEY.value,
111
      secretKey: sensitiveEnvironmentVariables.SCW_SECRET_KEY.value,
112
      organizationId: environmentVariables.SCW_DEFAULT_ORGANIZATION_ID.value,
113
      projectId: environmentVariables.SCW_PROJECT_ID.value,
114
    })
115

116
    // State of deployed infrastructure for each branch will be stored in the
117
    // same 'stack-terraform-state' bucket, with namespace in .tfstate filename.
118
    terraformBackend(this, `web-${namespace}`)
×
119

120
    // The database instance is shared for each namespace/branch we refer to it (DataScaleway)
121
    // but do not manage it through this stack
122
    const databaseInstance = new DataScalewayRdbInstance(this, 'dbInstance', {
×
123
      name: databaseInstanceName,
124
    })
125

126
    output('databaseHost', databaseInstance.endpointIp)
×
127
    output('databasePort', databaseInstance.endpointPort)
×
128

129
    const databaseName = namespaced(projectSlug)
×
130
    const databaseUser = namespaced(projectSlug)
×
131
    const databasePasswordVariable =
132
      sensitiveEnvironmentVariables.DATABASE_PASSWORD
×
133

134
    const rdbDatabaseUser = new RdbUser(this, 'databaseUser', {
×
135
      name: databaseUser,
136
      instanceId: databaseInstance.instanceId,
137
      password: databasePasswordVariable.value,
138
    })
139

140
    const database = new RdbDatabase(this, 'database', {
×
141
      name: databaseName,
142
      instanceId: databaseInstance.instanceId,
143
    })
144

145
    output('databaseUser', databaseUser)
×
146
    output('databaseName', databaseName)
×
147

148
    new RdbPrivilege(this, 'databasePrivilege', {
×
149
      instanceId: databaseInstance.instanceId,
150
      databaseName,
151
      userName: databaseUser,
152
      permission: 'all',
153
      dependsOn: [database, rdbDatabaseUser],
154
    })
155

156
    const uploadsBucket = new ObjectBucket(this, 'uploads', {
×
157
      name: namespaced(`${projectSlug}-uploads`),
158
      corsRule: [
159
        {
160
          allowedHeaders: ['*'],
161
          allowedMethods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
162
          maxAgeSeconds: 3000,
163
          exposeHeaders: ['Etag'],
164
          allowedOrigins: [`https://${hostname}`],
165
        },
166
      ],
167
    })
168

169
    output('uploadsBucketName', uploadsBucket.name)
×
170
    output('uploadsBucketEndpoint', uploadsBucket.endpoint)
×
171

172
    const containerNamespace = new DataScalewayContainerNamespace(
×
173
      this,
174
      'containerNamespace',
175
      { name: containerNamespaceName },
176
    )
177

178
    const emailFromAddress = isMain
×
179
      ? `bot@${mainDomain}`
180
      : `bot+${namespace}@${mainDomain}`
181

182
    const emailFromName = isMain
×
183
      ? projectTitle
184
      : `[${namespace}] ${projectTitle}`
185

186
    const databaseUrl = Fn.format('postgres://%s:%s@%s:%s/%s?sslmode=require', [
×
187
      databaseUser,
188
      databasePasswordVariable.value,
189
      databaseInstance.endpointIp,
190
      databaseInstance.endpointPort,
191
      databaseName,
192
    ]) as string
193

194
    // Changing the name will recreate a new container
195
    // The names fails with max length so we shorten it
196
    const maxContainerNameLength = 34
×
197
    const containerName =
198
      namespace.length > maxContainerNameLength
×
199
        ? namespace.slice(0, Math.max(0, maxContainerNameLength))
200
        : namespace
201

202
    const container = new Container(this, 'webContainer', {
×
203
      namespaceId: containerNamespace.namespaceId,
204
      registryImage: environmentVariables.WEB_CONTAINER_IMAGE.value,
205
      environmentVariables: {
206
        BREVO_USERS_LIST_ID: environmentVariables.BREVO_USERS_LIST_ID.value,
207
        EMAIL_FROM_ADDRESS: emailFromAddress,
208
        EMAIL_FROM_NAME: emailFromName,
209
        STACK_WEB_IMAGE: environmentVariables.WEB_CONTAINER_IMAGE.value,
210
        UPLOADS_BUCKET: uploadsBucket.name,
211
        BASE_URL: hostname,
212
        NEXTAUTH_URL: hostname,
213
        BRANCH: branch,
214
        NAMESPACE: namespace,
215
        // This env variable is reserved at the level of container namespace. We inject it here even if its shared.
216
        SCW_DEFAULT_REGION: region,
217
        SCALEWAY_GENERATIVE_API_SERVICE_URL:
218
          environmentVariables.SCALEWAY_GENERATIVE_API_SERVICE_URL.value,
219
        ALBERT_SERVICE_URL: environmentVariables.ALBERT_SERVICE_URL.value,
220
        SMTP_PORT: isMain ? smtpPort : '1025',
×
221
        DATASPACE_API_MOCK: isMain ? '0' : '1',
×
222
      },
223
      secretEnvironmentVariables: {
224
        API_ENTREPRISE_TOKEN:
225
          sensitiveEnvironmentVariables.API_ENTREPRISE_TOKEN.value,
226
        BREVO_API_KEY: isMain
×
227
          ? sensitiveEnvironmentVariables.BREVO_API_KEY.value
228
          : '',
229
        DATABASE_URL: databaseUrl,
230
        PROCONNECT_CLIENT_SECRET: isMain
×
231
          ? sensitiveEnvironmentVariables.PROCONNECT_MAIN_CLIENT_SECRET.value
232
          : sensitiveEnvironmentVariables.PROCONNECT_PREVIEW_CLIENT_SECRET
233
              .value,
234
        RDV_SERVICE_PUBLIC_OAUTH_CLIENT_ID: isMain
×
235
          ? sensitiveEnvironmentVariables
236
              .RDV_SERVICE_PUBLIC_MAIN_OAUTH_CLIENT_ID.value
237
          : sensitiveEnvironmentVariables
238
              .RDV_SERVICE_PUBLIC_PREVIEW_OAUTH_CLIENT_ID.value,
239
        RDV_SERVICE_PUBLIC_OAUTH_CLIENT_SECRET: isMain
×
240
          ? sensitiveEnvironmentVariables
241
              .RDV_SERVICE_PUBLIC_MAIN_OAUTH_CLIENT_SECRET.value
242
          : sensitiveEnvironmentVariables
243
              .RDV_SERVICE_PUBLIC_PREVIEW_OAUTH_CLIENT_SECRET.value,
244
        RDV_SERVICE_PUBLIC_API_KEY: isMain
×
245
          ? sensitiveEnvironmentVariables.RDV_API_KEY.value
246
          : sensitiveEnvironmentVariables.RDV_SERVICE_PUBLIC_PREVIEW_API_KEY
247
              .value,
248
        RDV_SERVICE_PUBLIC_WEBHOOK_SECRET:
249
          sensitiveEnvironmentVariables.RDV_SERVICE_PUBLIC_WEBHOOK_SECRET.value,
250
        INTERNAL_API_PRIVATE_KEY:
251
          sensitiveEnvironmentVariables.INTERNAL_API_PRIVATE_KEY.value,
252
        CONSEILLER_NUMERIQUE_MONGODB_URL:
253
          sensitiveEnvironmentVariables.CONSEILLER_NUMERIQUE_MONGODB_URL.value,
254
        HMAC_SECRET_KEY: sensitiveEnvironmentVariables.HMAC_SECRET_KEY.value,
255
        ALBERT_API_KEY: sensitiveEnvironmentVariables.ALBERT_API_KEY.value,
256
        BRAVE_API_KEY: sensitiveEnvironmentVariables.BRAVE_API_KEY.value,
257
        SMTP_USERNAME: isMain
×
258
          ? sensitiveEnvironmentVariables.SMTP_USERNAME.value
259
          : sensitiveEnvironmentVariables.SMTP_MAILDEV_USERNAME.value,
260
        SMTP_PASSWORD: isMain
×
261
          ? sensitiveEnvironmentVariables.SMTP_PASSWORD.value
262
          : sensitiveEnvironmentVariables.SMTP_MAILDEV_PASSWORD.value,
263
        SMTP_SERVER: isMain
×
264
          ? sensitiveEnvironmentVariables.SMTP_SERVER.value
265
          : 'maildev.coop-numerique.anct.gouv.fr',
266
        DATASPACE_API_KEY:
267
          sensitiveEnvironmentVariables.DATASPACE_API_KEY.value,
268
      },
269
      name: containerName,
270
      minScale: isMain ? 2 : namespace === 'dev' ? 1 : 0,
×
271
      maxScale: isMain ? 5 : 1,
×
272
      cpuLimit: isMain ? 3000 : 1120, // mVPCU
×
273
      memoryLimit: isMain ? 3072 : 2048, // mB
×
274
      deploy: true,
275
    })
276

277
    const domainZone = new DataScalewayDomainZone(this, 'dnsZone', {
×
278
      domain: isMain ? mainRootDomain : previewRootDomain,
×
279
      subdomain: isMain ? mainSubdomain : previewSubdomain,
×
280
    })
281

282
    const webDnsRecordConfig: DomainRecordConfig = subdomain
×
283
      ? {
284
          type: 'CNAME',
285
          dnsZone: domainZone.id,
286
          name: subdomain,
287
          data: `${container.domainName}.`,
288
          ttl: 60 * 5,
289
        }
290
      : {
291
          // Root domain record cannot be CNAME
292
          type: 'ALIAS',
293
          dnsZone: domainZone.id,
294
          name: '',
295
          data: `${container.domainName}.`,
296
          ttl: 60 * 5,
297
        }
298

299
    const webDnsRecord = new DomainRecord(
×
300
      this,
301
      'webDnsRecord',
302
      webDnsRecordConfig,
303
    )
304

305
    new ContainerDomain(this, 'webContainerDomain', {
×
306
      containerId: container.id,
307
      hostname,
308
      dependsOn: [webDnsRecord, container],
309
    })
310

311
    if (isMain) {
×
312
      // Weekly backup job
313
      createJobExecutionCron(this, {
×
314
        name: `backup-${namespace}-database-weekly`,
315
        job: {
316
          name: 'backup-database',
317
          payload: {
318
            databaseName,
319
            type: 'weekly',
320
          },
321
        },
322
        schedule: '0 0 * * 0',
323
        containerId: container.id,
324
      })
325

326
      // Daily backup job
327
      createJobExecutionCron(this, {
×
328
        name: `backup-${namespace}-database-daily`,
329
        job: {
330
          name: 'backup-database',
331
          payload: {
332
            databaseName,
333
            type: 'daily',
334
          },
335
        },
336
        schedule: '0 0 * * *',
337
        containerId: container.id,
338
      })
339

340
      // Daily sync users from Dataspace API at 2 AM
341
      createJobExecutionCron(this, {
×
342
        name: 'sync-users-from-dataspace',
343
        job: {
344
          name: 'sync-users-from-dataspace',
345
        },
346
        schedule: '0 2 * * *',
347
        containerId: container.id,
348
      })
349

350
      // Daily update fix users roles
351
      createJobExecutionCron(this, {
×
352
        name: 'fix-users-roles',
353
        job: {
354
          name: 'fix-users-roles',
355
        },
356
        schedule: '0 0 * * *',
357
        containerId: container.id,
358
      })
359

360
      // Daily send reminders emails for incomplete signups
361
      createJobExecutionCron(this, {
×
362
        name: 'inactive-users-reminders',
363
        job: {
364
          name: 'inactive-users-reminders',
365
        },
366
        schedule: '0 0 * * *',
367
        containerId: container.id,
368
      })
369

370
      // Daily cleanup of orphan Brevo contacts at 3 AM
371
      createJobExecutionCron(this, {
×
372
        name: 'remove-orphan-brevo-contacts',
373
        job: {
374
          name: 'remove-orphan-brevo-contacts',
375
        },
376
        schedule: '0 3 * * *',
377
        containerId: container.id,
378
      })
379

380
      // Hourly backup job
381
      createJobExecutionCron(this, {
×
382
        name: `backup-${namespace}-database-hourly`,
383
        job: {
384
          name: 'backup-database',
385
          payload: {
386
            databaseName,
387
            type: 'hourly',
388
          },
389
        },
390
        schedule: '0 * * * *',
391
        containerId: container.id,
392
      })
393

394
      // Daily normalize structures employeuses at 4 AM
NEW
395
      createJobExecutionCron(this, {
×
396
        name: 'normalize-structures-employeuses',
397
        job: {
398
          name: 'normalize-structures-employeuses',
399
        },
400
        schedule: '0 4 * * *',
401
        containerId: container.id,
402
      })
403
    }
404

405
    // Daily sync RDVSP data
406
    // Only for dev and main environments
407
    if (namespace === 'dev' || namespace === 'main') {
×
408
      createJobExecutionCron(this, {
×
409
        name: 'sync-rdvsp-data',
410
        job: {
411
          name: 'sync-rdvsp-data',
412
        },
413
        schedule: '0 2 * * *',
414
        containerId: container.id,
415
      })
416
    }
417

418
    createJobExecutionCron(this, {
×
419
      name: `update-structures-cartographie-nationale`,
420
      job: {
421
        name: 'update-structures-cartographie-nationale',
422
        payload: undefined,
423
      },
424
      schedule: '0 3 * * *',
425
      containerId: container.id,
426
    })
427

428
    output('webBaseUrl', hostname)
×
429
    output('containerDomainName', container.domainName)
×
430
    output('databaseUrl', databaseUrl, 'sensitive')
×
431
    output('databasePassword', databasePasswordVariable.value, 'sensitive')
×
432
    output(
×
433
      'webContainerStatus',
434
      container.status as WebCdkOutput['webContainerStatus'],
435
    )
436
    output('webContainerId', container.id)
×
437
    output('webContainerImage', environmentVariables.WEB_CONTAINER_IMAGE.value)
×
438
  }
439
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc