Skip to content

Commit 3b0e7d5

Browse files
committed
feat: add flexible custom attributes system to replace hardcoded prerogatives
- Create software_attribute_definitions table with attribute metadata - Add customAttributes JSONB column to softwares table with GIN index - Migrate existing prerogatives (isPresentInSupportContract, isFromFrenchPublicService, doRespectRgaa) to custom attributes - Create AttributeKind type ('boolean' | 'string' | 'number' | 'date' | 'url') - Add AttributeDefinition and AttributeValue types - Implement repository layer for attribute definitions - Create getAttributeDefinitions use case - Remove deprecated Prerogatives type This enables project-specific attributes without code changes, making the system more generic and reusable across different deployments. Note: Type errors remain in use cases, tests, and adapters - will be fixed in follow-up commits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> feat: add software customAttributes and a table to define them software_attribute_definitions
1 parent 91528df commit 3b0e7d5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+1158
-867
lines changed

api/scripts/seed.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,7 @@ const seed = async () => {
7171
similarSoftwareExternalDataIds: [],
7272
softwareLogoUrl: "https://react.dev/favicon.ico",
7373
softwareKeywords: ["javascript", "ui", "frontend", "library"],
74-
isPresentInSupportContract: false,
75-
isFromFrenchPublicService: false,
76-
doRespectRgaa: null
74+
customAttributes: {}
7775
},
7876
{
7977
softwareName: "Git",
@@ -89,9 +87,7 @@ const seed = async () => {
8987
similarSoftwareExternalDataIds: [],
9088
softwareLogoUrl: "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png",
9189
softwareKeywords: ["vcs", "version control", "git", "scm"],
92-
isPresentInSupportContract: false,
93-
isFromFrenchPublicService: false,
94-
doRespectRgaa: null
90+
customAttributes: {}
9591
},
9692
{
9793
softwareName: "OpenOffice",
@@ -108,9 +104,7 @@ const seed = async () => {
108104
similarSoftwareExternalDataIds: [],
109105
softwareLogoUrl: "https://www.openoffice.org/images/AOO_logos/AOO_Logo_FullColor.svg",
110106
softwareKeywords: ["office", "suite", "word", "spreadsheet", "presentation"],
111-
isPresentInSupportContract: false,
112-
isFromFrenchPublicService: false,
113-
doRespectRgaa: null
107+
customAttributes: {}
114108
},
115109
{
116110
softwareName: "VLC media player",
@@ -126,9 +120,7 @@ const seed = async () => {
126120
similarSoftwareExternalDataIds: [],
127121
softwareLogoUrl: "https://www.videolan.org/images/favicon.png",
128122
softwareKeywords: ["media", "player", "video", "audio", "vlc"],
129-
isPresentInSupportContract: false,
130-
isFromFrenchPublicService: false,
131-
doRespectRgaa: null
123+
customAttributes: {}
132124
},
133125
{
134126
softwareName: "GIMP",
@@ -144,9 +136,7 @@ const seed = async () => {
144136
similarSoftwareExternalDataIds: [],
145137
softwareLogoUrl: "https://www.gimp.org/images/wilber-big.png",
146138
softwareKeywords: ["image", "editor", "graphics", "gimp"],
147-
isPresentInSupportContract: false,
148-
isFromFrenchPublicService: false,
149-
doRespectRgaa: null
139+
customAttributes: {}
150140
},
151141
{
152142
softwareName: "Onyxia",
@@ -163,9 +153,7 @@ const seed = async () => {
163153
softwareLogoUrl:
164154
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Onyxia.svg/250px-Onyxia.svg.png",
165155
softwareKeywords: ["hébergement", "hosting", "plateforme", "platform", "cloud", "nuage"],
166-
isPresentInSupportContract: false,
167-
isFromFrenchPublicService: true,
168-
doRespectRgaa: false
156+
customAttributes: {}
169157
}
170158
];
171159

api/src/core/adapters/comptoirDuLibre/getCDLFormData.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ const formatCDLSoftwareToExternalData = async (
3030
similarSoftwareExternalDataIds: [],
3131
softwareLogoUrl: logoUrl,
3232
softwareKeywords: keywords,
33-
34-
isPresentInSupportContract: false,
35-
isFromFrenchPublicService: false,
36-
doRespectRgaa: null
33+
customAttributes: undefined
3734
};
3835
};
3936

api/src/core/adapters/dbApi/kysely/createGetCompiledData.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ export const createGetCompiledData = (db: Kysely<Database>) => async (): Promise
4242
"s.categories",
4343
"s.dereferencing",
4444
"s.description",
45-
"s.doRespectRgaa",
4645
"s.generalInfoMd",
47-
"s.isFromFrenchPublicService",
48-
"s.isPresentInSupportContract",
46+
"s.customAttributes",
4947
"s.isStillInObservation",
5048
"s.keywords",
5149
"s.license",
@@ -94,7 +92,7 @@ export const createGetCompiledData = (db: Kysely<Database>) => async (): Promise
9492
addedByUserId,
9593
similarExternalSoftwares,
9694
dereferencing,
97-
doRespectRgaa,
95+
customAttributes,
9896
users,
9997
referents,
10098
instances,
@@ -115,7 +113,7 @@ export const createGetCompiledData = (db: Kysely<Database>) => async (): Promise
115113
addedByUserEmail: agentById[addedByUserId].email,
116114
updateTime: new Date(+updateTime).getTime(),
117115
referencedSinceTime: new Date(+referencedSinceTime).getTime(),
118-
doRespectRgaa,
116+
customAttributes,
119117
softwareExternalData: softwareExternalData ?? undefined,
120118
latestVersion: version,
121119
dereferencing: dereferencing ?? undefined,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-FileCopyrightText: 2021-2025 DINUM <[email protected]>
2+
// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes
3+
// SPDX-License-Identifier: MIT
4+
5+
import { Kysely } from "kysely";
6+
import { AttributeDefinitionRepository } from "../../../ports/DbApiV2";
7+
import { Database } from "./kysely.database";
8+
import { stripNullOrUndefinedValues } from "./kysely.utils";
9+
10+
export const createPgAttributeDefinitionRepository = (db: Kysely<Database>): AttributeDefinitionRepository => ({
11+
getAll: async () =>
12+
db
13+
.selectFrom("software_attribute_definitions")
14+
.selectAll()
15+
.orderBy("displayOrder", "asc")
16+
.execute()
17+
.then(rows => rows.map(row => stripNullOrUndefinedValues(row))),
18+
getByName: async (name: string) =>
19+
db
20+
.selectFrom("software_attribute_definitions")
21+
.selectAll()
22+
.where("name", "=", name)
23+
.executeTakeFirst()
24+
.then(row => (row ? stripNullOrUndefinedValues(row) : undefined))
25+
});

api/src/core/adapters/dbApi/kysely/createPgDbApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createPgSessionRepository } from "./createPgSessionRepository";
1111
import { createPgSoftwareExternalDataRepository } from "./createPgSoftwareExternalDataRepository";
1212
import { createPgSoftwareRepository } from "./createPgSoftwareRepository";
1313
import { createPgSourceRepository } from "./createPgSourceRepository";
14+
import { createPgAttributeDefinitionRepository } from "./createPgAttributeDefinitionRepository";
1415
import {
1516
createPgSoftwareReferentRepository,
1617
createPgSoftwareUserRepository
@@ -27,6 +28,7 @@ export const createKyselyPgDbApi = (db: Kysely<Database>): DbApiV2 => {
2728
softwareReferent: createPgSoftwareReferentRepository(db),
2829
softwareUser: createPgSoftwareUserRepository(db),
2930
session: createPgSessionRepository(db),
31+
attributeDefinition: createPgAttributeDefinitionRepository(db),
3032
getCompiledDataPrivate: createGetCompiledData(db)
3133
};
3234
};

api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ export const createPgSoftwareRepository = (db: Kysely<Database>): SoftwareReposi
3838
referencedSinceTime,
3939
isStillInObservation,
4040
dereferencing,
41-
doRespectRgaa,
42-
isFromFrenchPublicService,
43-
isPresentInSupportContract,
41+
customAttributes,
4442
softwareType,
4543
workshopUrls,
4644
categories,
@@ -67,9 +65,7 @@ export const createPgSoftwareRepository = (db: Kysely<Database>): SoftwareReposi
6765
updateTime: now,
6866
dereferencing: JSON.stringify(dereferencing),
6967
isStillInObservation, // Legacy field from SILL imported
70-
doRespectRgaa,
71-
isFromFrenchPublicService,
72-
isPresentInSupportContract,
68+
customAttributes: JSON.stringify(customAttributes),
7369
softwareType: JSON.stringify(softwareType),
7470
workshopUrls: JSON.stringify(workshopUrls), // Legacy field from SILL imported
7571
categories: JSON.stringify(categories), // Legacy field from SILL imported
@@ -92,9 +88,7 @@ export const createPgSoftwareRepository = (db: Kysely<Database>): SoftwareReposi
9288
versionMin,
9389
dereferencing,
9490
isStillInObservation,
95-
doRespectRgaa,
96-
isFromFrenchPublicService,
97-
isPresentInSupportContract,
91+
customAttributes,
9892
softwareType,
9993
workshopUrls,
10094
categories,
@@ -118,9 +112,7 @@ export const createPgSoftwareRepository = (db: Kysely<Database>): SoftwareReposi
118112
dereferencing: JSON.stringify(dereferencing),
119113
updateTime: now,
120114
isStillInObservation: false,
121-
doRespectRgaa,
122-
isFromFrenchPublicService,
123-
isPresentInSupportContract,
115+
customAttributes: JSON.stringify(customAttributes),
124116
softwareType: JSON.stringify(softwareType),
125117
workshopUrls: JSON.stringify(workshopUrls),
126118
categories: JSON.stringify(categories),

api/src/core/adapters/dbApi/kysely/kysely.database.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export type Database = {
7575
softwares__similar_software_external_datas: SimilarExternalSoftwareExternalDataTable;
7676
sources: SourcesTable;
7777
user_sessions: SessionsTable;
78+
software_attribute_definitions: SoftwareAttributeDefinitionsTable;
7879
};
7980

8081
type UsersTable = {
@@ -122,6 +123,7 @@ type InstancesTable = {
122123
type ExternalId = string;
123124
export type ExternalDataOriginKind = "wikidata" | "HAL" | "ComptoirDuLibre" | "CNLL" | "Zenodo";
124125
type LocalizedString = Partial<Record<string, string>>;
126+
export type AttributeKind = "boolean" | "string" | "number" | "date" | "url";
125127

126128
type SimilarExternalSoftwareExternalDataTable = {
127129
softwareId: number;
@@ -137,6 +139,21 @@ type SourcesTable = {
137139
description: JSONColumnType<LocalizedString> | null;
138140
};
139141

142+
type SoftwareAttributeDefinitionsTable = {
143+
name: string;
144+
kind: AttributeKind;
145+
label: JSONColumnType<LocalizedString>;
146+
description: JSONColumnType<LocalizedString> | null;
147+
displayInForm: boolean;
148+
displayInDetails: boolean;
149+
displayInCardIcon: "computer" | "france" | "question" | "thumbs-up" | "chat" | "star" | null;
150+
enableFiltering: boolean;
151+
required: boolean;
152+
displayOrder: number;
153+
createdAt: Date;
154+
updatedAt: Date;
155+
};
156+
140157
export type SoftwareExternalDatasTable = {
141158
externalId: ExternalId;
142159
sourceSlug: string;
@@ -181,9 +198,7 @@ type SoftwaresTable = {
181198
lastRecommendedVersion?: string;
182199
}> | null;
183200
isStillInObservation: boolean;
184-
doRespectRgaa: boolean | null;
185-
isFromFrenchPublicService: boolean;
186-
isPresentInSupportContract: boolean;
201+
customAttributes: JSONColumnType<Record<string, any>> | null;
187202
license: string;
188203
softwareType: JSONColumnType<SoftwareType>;
189204
versionMin: string | null;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// SPDX-FileCopyrightText: 2021-2025 DINUM <[email protected]>
2+
// SPDX-FileCopyrightText: 2024-2025 Université Grenoble Alpes
3+
// SPDX-License-Identifier: MIT
4+
5+
import { sql, type Kysely } from "kysely";
6+
7+
export async function up(db: Kysely<any>): Promise<void> {
8+
// Create enum type for attribute kinds
9+
await db.schema.createType("attribute_kind").asEnum(["boolean", "string", "number", "date", "url"]).execute();
10+
await db.schema
11+
.createType("display_in_card_icon_kind")
12+
.asEnum(["computer", "france", "question", "thumbs-up", "chat", "star"])
13+
.execute();
14+
15+
// Create software_attribute_definitions table
16+
await db.schema
17+
.createTable("software_attribute_definitions")
18+
.addColumn("name", "text", col => col.primaryKey())
19+
.addColumn("kind", sql`attribute_kind`, col => col.notNull())
20+
.addColumn("label", "jsonb", col => col.notNull())
21+
.addColumn("description", "jsonb")
22+
.addColumn("displayInForm", "boolean", col => col.notNull().defaultTo(true))
23+
.addColumn("displayInDetails", "boolean", col => col.notNull().defaultTo(true))
24+
.addColumn("displayInCardIcon", sql`display_in_card_icon_kind`)
25+
.addColumn("enableFiltering", "boolean", col => col.notNull().defaultTo(false))
26+
.addColumn("required", "boolean", col => col.notNull().defaultTo(false))
27+
.addColumn("displayOrder", "integer", col => col.notNull().defaultTo(0))
28+
.addColumn("createdAt", "timestamptz", col => col.notNull().defaultTo(sql`NOW()`))
29+
.addColumn("updatedAt", "timestamptz", col => col.notNull().defaultTo(sql`NOW()`))
30+
.execute();
31+
32+
// Seed existing prerogatives as attribute definitions
33+
await db
34+
.insertInto("software_attribute_definitions")
35+
.values([
36+
{
37+
name: "isPresentInSupportContract",
38+
kind: sql`'boolean'::attribute_kind`,
39+
label: sql`'{"en": "Present in support contract", "fr": "Présent dans le marché de support"}'::jsonb`,
40+
description: sql`'{"en": "The DGFIP manages two inter-ministerial markets: support (Atos) and expertise (multiple contractors) for open-source software, covering maintenance, monitoring, and expert services. https://code.gouv.fr/fr/utiliser/marches-interministeriels-support-expertise-logiciels-libres", "fr": "La DGFIP pilote deux marchés interministériels : support (Atos) et expertise (plusieurs titulaires) pour logiciels libres, couvrant maintenance, veille et prestations d’expertise. https://code.gouv.fr/fr/utiliser/marches-interministeriels-support-expertise-logiciels-libres"}'::jsonb`,
41+
displayInForm: true,
42+
displayInDetails: true,
43+
displayInCardIcon: "question",
44+
enableFiltering: true,
45+
required: true,
46+
displayOrder: 1
47+
},
48+
{
49+
name: "isFromFrenchPublicService",
50+
kind: sql`'boolean'::attribute_kind`,
51+
label: sql`'{"en": "Software developed by French public services", "fr": "Logiciel développé par les services publics français"}'::jsonb`,
52+
displayInForm: true,
53+
displayInDetails: true,
54+
displayInCardIcon: "france",
55+
enableFiltering: true,
56+
required: true,
57+
displayOrder: 2
58+
},
59+
{
60+
name: "doRespectRgaa",
61+
kind: sql`'boolean'::attribute_kind`,
62+
label: sql`'{"en": "RGAA compliant", "fr": "Respecte les normes RGAA"}'::jsonb`,
63+
description: sql`'{"en": "Référentiel général d’amélioration de l’accessibilité. Details on : https://accessibilite.numerique.gouv.fr", "fr": "Référentiel général d’amélioration de l’accessibilité. La DINUM édite ce référentiel général d’amélioration de l’accessibilité. Détails sur : https://accessibilite.numerique.gouv.fr"}'::jsonb`,
64+
displayInForm: true,
65+
displayInDetails: true,
66+
displayInCardIcon: null,
67+
enableFiltering: true,
68+
required: false,
69+
displayOrder: 3
70+
}
71+
])
72+
.execute();
73+
74+
// Add customAttributes column to softwares
75+
await db.schema
76+
.alterTable("softwares")
77+
.addColumn("customAttributes", "jsonb", col => col.notNull().defaultTo(sql`'{}'::jsonb`))
78+
.execute();
79+
80+
// Migrate existing data to customAttributes
81+
await db
82+
.updateTable("softwares")
83+
.set({
84+
customAttributes: sql`jsonb_build_object(
85+
'isPresentInSupportContract', "isPresentInSupportContract",
86+
'isFromFrenchPublicService', "isFromFrenchPublicService",
87+
'doRespectRgaa', "doRespectRgaa"
88+
)`
89+
})
90+
.execute();
91+
92+
// Create GIN index for efficient JSONB queries
93+
await sql`CREATE INDEX softwares_customAttributes_idx ON softwares USING GIN ("customAttributes")`.execute(db);
94+
95+
// Drop old columns
96+
await db.schema
97+
.alterTable("softwares")
98+
.dropColumn("isPresentInSupportContract")
99+
.dropColumn("isFromFrenchPublicService")
100+
.dropColumn("doRespectRgaa")
101+
.execute();
102+
}
103+
104+
export async function down(db: Kysely<any>): Promise<void> {
105+
// Restore old columns
106+
await db.schema
107+
.alterTable("softwares")
108+
.addColumn("isPresentInSupportContract", "boolean", col => col.notNull().defaultTo(false))
109+
.addColumn("isFromFrenchPublicService", "boolean", col => col.notNull().defaultTo(false))
110+
.addColumn("doRespectRgaa", "boolean")
111+
.execute();
112+
113+
// Migrate data back
114+
await db
115+
.updateTable("softwares")
116+
.set({
117+
isPresentInSupportContract: sql`COALESCE(("customAttributes"->>'isPresentInSupportContract')::boolean, false)`,
118+
isFromFrenchPublicService: sql`COALESCE(("customAttributes"->>'isFromFrenchPublicService')::boolean, false)`,
119+
doRespectRgaa: sql`("customAttributes"->>'doRespectRgaa')::boolean`
120+
})
121+
.execute();
122+
123+
// Drop index (by name)
124+
await db.schema.dropIndex("softwares_customAttributes_idx").ifExists().execute();
125+
126+
// Drop customAttributes column
127+
await db.schema.alterTable("softwares").dropColumn("customAttributes").execute();
128+
129+
// Drop tables and types
130+
await db.schema.dropTable("software_attribute_definitions").execute();
131+
await db.schema.dropType("attribute_kind").execute();
132+
await db.schema.dropType("display_in_card_icon_kind").execute();
133+
}

0 commit comments

Comments
 (0)