@portaki/module-sections 1.0.0

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.
@@ -0,0 +1,57 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
3
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5
+ <modelVersion>4.0.0</modelVersion>
6
+
7
+ <groupId>app.portaki.module</groupId>
8
+ <artifactId>sections-backend</artifactId>
9
+ <version>0.1.0</version>
10
+ <packaging>jar</packaging>
11
+
12
+ <name>Sections module (Java backend)</name>
13
+ <description>Gateway handlers and persistence for the Portaki sections editorial module.</description>
14
+
15
+ <properties>
16
+ <java.version>21</java.version>
17
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
18
+ </properties>
19
+
20
+ <dependencies>
21
+ <dependency>
22
+ <groupId>app.portaki</groupId>
23
+ <artifactId>portaki-module-sdk</artifactId>
24
+ <version>0.6.0</version>
25
+ </dependency>
26
+ <dependency>
27
+ <groupId>org.springframework</groupId>
28
+ <artifactId>spring-context</artifactId>
29
+ <version>6.2.5</version>
30
+ <scope>provided</scope>
31
+ </dependency>
32
+ <dependency>
33
+ <groupId>org.springframework</groupId>
34
+ <artifactId>spring-jdbc</artifactId>
35
+ <version>6.2.5</version>
36
+ <scope>provided</scope>
37
+ </dependency>
38
+ <dependency>
39
+ <groupId>com.fasterxml.jackson.core</groupId>
40
+ <artifactId>jackson-databind</artifactId>
41
+ <version>2.18.2</version>
42
+ </dependency>
43
+ </dependencies>
44
+
45
+ <build>
46
+ <plugins>
47
+ <plugin>
48
+ <groupId>org.apache.maven.plugins</groupId>
49
+ <artifactId>maven-compiler-plugin</artifactId>
50
+ <version>3.14.0</version>
51
+ <configuration>
52
+ <release>${java.version}</release>
53
+ </configuration>
54
+ </plugin>
55
+ </plugins>
56
+ </build>
57
+ </project>
@@ -0,0 +1,251 @@
1
+ package app.portaki.module.sections;
2
+
3
+ import java.sql.ResultSet;
4
+ import java.sql.SQLException;
5
+ import java.time.Instant;
6
+ import java.util.HashMap;
7
+ import java.util.List;
8
+ import java.util.Map;
9
+ import java.util.UUID;
10
+
11
+ import org.springframework.jdbc.core.JdbcTemplate;
12
+ import org.springframework.stereotype.Component;
13
+
14
+ import com.fasterxml.jackson.core.JsonProcessingException;
15
+ import com.fasterxml.jackson.databind.JsonNode;
16
+ import com.fasterxml.jackson.databind.ObjectMapper;
17
+
18
+ import app.portaki.sdk.gateway.GatewayModuleContext;
19
+ import app.portaki.sdk.gateway.PortakiCommandHandler;
20
+ import app.portaki.sdk.gateway.PortakiQueryHandler;
21
+ import app.portaki.sdk.module.PortakiModule;
22
+
23
+ @Component
24
+ @PortakiModule("sections")
25
+ public class SectionsGatewayModule {
26
+
27
+ private final JdbcTemplate jdbc;
28
+ private final ObjectMapper objectMapper;
29
+
30
+ public SectionsGatewayModule(JdbcTemplate jdbc, ObjectMapper objectMapper) {
31
+ this.jdbc = jdbc;
32
+ this.objectMapper = objectMapper;
33
+ }
34
+
35
+ @PortakiQueryHandler(value = "sections.list", scope = "property:read")
36
+ public Map<String, Object> listSections(Map<String, Object> params, GatewayModuleContext ctx) {
37
+ UUID tenantId = ctx.tenantIdInternal();
38
+ UUID propertyId = UUID.fromString(ctx.propertyId());
39
+ List<Map<String, Object>> rows =
40
+ jdbc.query(
41
+ """
42
+ SELECT id, sort_order, title_fr, title_en, content_fr, content_en, updated_at
43
+ FROM t_e_module_sections_item
44
+ WHERE tenant_id = ? AND property_id = ?
45
+ ORDER BY sort_order ASC, created_at ASC
46
+ """,
47
+ (rs, rowNum) -> toSectionRow(rs),
48
+ tenantId,
49
+ propertyId);
50
+ return Map.of("sections", rows);
51
+ }
52
+
53
+ @PortakiCommandHandler(value = "sections.section.save", scope = "host:property:write")
54
+ public void saveSection(Map<String, Object> params, GatewayModuleContext ctx) {
55
+ UUID tenantId = ctx.tenantIdInternal();
56
+ UUID propertyId = UUID.fromString(ctx.propertyId());
57
+ String idRaw = stringParam(params, "id");
58
+ UUID id = idRaw == null || idRaw.isBlank() ? UUID.randomUUID() : UUID.fromString(idRaw);
59
+ String titleFr = stringParam(params, "titleFr");
60
+ String titleEn = stringParam(params, "titleEn");
61
+ JsonNode contentFr = readJsonParam(params, "contentFr");
62
+ JsonNode contentEn = readJsonParam(params, "contentEn");
63
+ int sortOrder = intParam(params, "sortOrder", nextSortOrder(tenantId, propertyId, id));
64
+
65
+ if (titleFr == null || titleFr.isBlank()) {
66
+ throw new IllegalArgumentException("title_fr_required");
67
+ }
68
+ if (titleEn == null || titleEn.isBlank()) {
69
+ titleEn = titleFr;
70
+ }
71
+
72
+ boolean exists =
73
+ Boolean.TRUE.equals(
74
+ jdbc.queryForObject(
75
+ """
76
+ SELECT EXISTS(
77
+ SELECT 1 FROM t_e_module_sections_item
78
+ WHERE id = ? AND tenant_id = ? AND property_id = ?
79
+ )
80
+ """,
81
+ Boolean.class,
82
+ id,
83
+ tenantId,
84
+ propertyId));
85
+
86
+ if (exists) {
87
+ jdbc.update(
88
+ """
89
+ UPDATE t_e_module_sections_item
90
+ SET title_fr = ?, title_en = ?, content_fr = ?::jsonb, content_en = ?::jsonb,
91
+ sort_order = ?, updated_at = ?
92
+ WHERE id = ? AND tenant_id = ? AND property_id = ?
93
+ """,
94
+ titleFr,
95
+ titleEn,
96
+ jsonOrNull(contentFr),
97
+ jsonOrNull(contentEn),
98
+ sortOrder,
99
+ Instant.now(),
100
+ id,
101
+ tenantId,
102
+ propertyId);
103
+ } else {
104
+ jdbc.update(
105
+ """
106
+ INSERT INTO t_e_module_sections_item (
107
+ id, tenant_id, property_id, sort_order, title_fr, title_en,
108
+ content_fr, content_en, created_at, updated_at
109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?)
110
+ """,
111
+ id,
112
+ tenantId,
113
+ propertyId,
114
+ sortOrder,
115
+ titleFr,
116
+ titleEn,
117
+ jsonOrNull(contentFr),
118
+ jsonOrNull(contentEn),
119
+ Instant.now(),
120
+ Instant.now());
121
+ }
122
+
123
+ }
124
+
125
+ @PortakiCommandHandler(value = "sections.section.delete", scope = "host:property:write")
126
+ public void deleteSection(Map<String, Object> params, GatewayModuleContext ctx) {
127
+ UUID tenantId = ctx.tenantIdInternal();
128
+ UUID propertyId = UUID.fromString(ctx.propertyId());
129
+ String idRaw = stringParam(params, "id");
130
+ if (idRaw == null || idRaw.isBlank()) {
131
+ throw new IllegalArgumentException("id_required");
132
+ }
133
+ UUID id = UUID.fromString(idRaw);
134
+ int deleted =
135
+ jdbc.update(
136
+ """
137
+ DELETE FROM t_e_module_sections_item
138
+ WHERE id = ? AND tenant_id = ? AND property_id = ?
139
+ """,
140
+ id,
141
+ tenantId,
142
+ propertyId);
143
+ if (deleted == 0) {
144
+ throw new IllegalArgumentException("section_not_found");
145
+ }
146
+ }
147
+
148
+ @PortakiCommandHandler(value = "sections.reorder", scope = "host:property:write")
149
+ public void reorder(Map<String, Object> params, GatewayModuleContext ctx) {
150
+ UUID tenantId = ctx.tenantIdInternal();
151
+ UUID propertyId = UUID.fromString(ctx.propertyId());
152
+ Object raw = params.get("orderedIds");
153
+ if (!(raw instanceof List<?> list) || list.isEmpty()) {
154
+ throw new IllegalArgumentException("ordered_ids_required");
155
+ }
156
+ int order = 0;
157
+ for (Object item : list) {
158
+ if (item == null) {
159
+ continue;
160
+ }
161
+ UUID id = UUID.fromString(item.toString());
162
+ jdbc.update(
163
+ """
164
+ UPDATE t_e_module_sections_item
165
+ SET sort_order = ?, updated_at = ?
166
+ WHERE id = ? AND tenant_id = ? AND property_id = ?
167
+ """,
168
+ order++,
169
+ Instant.now(),
170
+ id,
171
+ tenantId,
172
+ propertyId);
173
+ }
174
+ }
175
+
176
+ private Map<String, Object> toSectionRow(ResultSet rs) throws SQLException {
177
+ Map<String, Object> row = new HashMap<>();
178
+ row.put("id", rs.getObject("id", UUID.class).toString());
179
+ row.put("sortOrder", rs.getInt("sort_order"));
180
+ row.put("titleFr", rs.getString("title_fr"));
181
+ row.put("titleEn", rs.getString("title_en"));
182
+ row.put("contentFr", parseJsonColumn(rs.getString("content_fr")));
183
+ row.put("contentEn", parseJsonColumn(rs.getString("content_en")));
184
+ Instant updatedAt = rs.getObject("updated_at", Instant.class);
185
+ if (updatedAt != null) {
186
+ row.put("updatedAt", updatedAt.toString());
187
+ }
188
+ return row;
189
+ }
190
+
191
+ private Object parseJsonColumn(String raw) {
192
+ if (raw == null || raw.isBlank()) {
193
+ return null;
194
+ }
195
+ try {
196
+ return objectMapper.readValue(raw, Object.class);
197
+ } catch (JsonProcessingException e) {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ private int nextSortOrder(UUID tenantId, UUID propertyId, UUID exceptId) {
203
+ Integer max =
204
+ jdbc.queryForObject(
205
+ """
206
+ SELECT COALESCE(MAX(sort_order), -1)
207
+ FROM t_e_module_sections_item
208
+ WHERE tenant_id = ? AND property_id = ? AND id <> ?
209
+ """,
210
+ Integer.class,
211
+ tenantId,
212
+ propertyId,
213
+ exceptId);
214
+ return max == null ? 0 : max + 1;
215
+ }
216
+
217
+ private static String stringParam(Map<String, Object> params, String key) {
218
+ Object v = params.get(key);
219
+ return v == null ? null : v.toString();
220
+ }
221
+
222
+ private static int intParam(Map<String, Object> params, String key, int defaultValue) {
223
+ Object v = params.get(key);
224
+ if (v == null) {
225
+ return defaultValue;
226
+ }
227
+ if (v instanceof Number n) {
228
+ return n.intValue();
229
+ }
230
+ return Integer.parseInt(v.toString());
231
+ }
232
+
233
+ private JsonNode readJsonParam(Map<String, Object> params, String key) {
234
+ Object v = params.get(key);
235
+ if (v == null) {
236
+ return null;
237
+ }
238
+ return objectMapper.valueToTree(v);
239
+ }
240
+
241
+ private String jsonOrNull(JsonNode node) {
242
+ if (node == null || node.isNull()) {
243
+ return null;
244
+ }
245
+ try {
246
+ return objectMapper.writeValueAsString(node);
247
+ } catch (JsonProcessingException e) {
248
+ throw new IllegalArgumentException("invalid_json", e);
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,8 @@
1
+ package app.portaki.module.sections;
2
+
3
+ import org.springframework.context.annotation.ComponentScan;
4
+ import org.springframework.context.annotation.Configuration;
5
+
6
+ @Configuration
7
+ @ComponentScan(basePackageClasses = SectionsGatewayModule.class)
8
+ public class SectionsModuleBackendConfiguration {}
@@ -0,0 +1,3 @@
1
+ artifactId=sections-backend
2
+ groupId=app.portaki.module
3
+ version=0.1.0
@@ -0,0 +1,2 @@
1
+ app/portaki/module/sections/SectionsGatewayModule.class
2
+ app/portaki/module/sections/SectionsModuleBackendConfiguration.class
@@ -0,0 +1,2 @@
1
+ /Users/cyrilcolinet/Documents/Projects/PortakiApp/Repositories/portaki-modules/modules/sections/backend/src/main/java/app/portaki/module/sections/SectionsGatewayModule.java
2
+ /Users/cyrilcolinet/Documents/Projects/PortakiApp/Repositories/portaki-modules/modules/sections/backend/src/main/java/app/portaki/module/sections/SectionsModuleBackendConfiguration.java
package/db/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # Schéma `sections`
2
+
3
+ - **Propriété** : module `sections` (`app.portaki.module.sections`).
4
+ - **Source de vérité** : `schema.sql` dans ce dossier.
5
+ - **Application aujourd’hui** : copie versionnée dans `portaki-api/.../db/migration/V58__module_sections_items.sql` (Flyway unique sur la base core).
6
+ - **Cible** : job **Atlas** / `portaki-module-migrator` (schéma Postgres dédié ou préfixe module) — voir `portaki-internal-docs/MODULE_PLATFORM_PREPARATION.md`.
7
+
8
+ Ne pas ajouter d’entités JPA pour ce module dans `portaki-api` : persistance dans `backend/` uniquement.
package/db/schema.sql ADDED
@@ -0,0 +1,18 @@
1
+ -- Canonical DDL for module `sections` (review in portaki-modules).
2
+ -- Runtime: copied to portaki-api Flyway until Atlas module migrator exists.
3
+
4
+ CREATE TABLE t_e_module_sections_item (
5
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6
+ tenant_id UUID NOT NULL REFERENCES t_e_tenants (id),
7
+ property_id UUID NOT NULL REFERENCES t_e_properties (id) ON DELETE CASCADE,
8
+ sort_order INT NOT NULL DEFAULT 0,
9
+ title_fr TEXT NOT NULL DEFAULT '',
10
+ title_en TEXT NOT NULL DEFAULT '',
11
+ content_fr JSONB,
12
+ content_en JSONB,
13
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
14
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
15
+ );
16
+
17
+ CREATE INDEX t_e_module_sections_item_property_idx
18
+ ON t_e_module_sections_item (tenant_id, property_id);
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@portaki/module-sections",
3
+ "version": "1.0.0",
4
+ "description": "Portaki module — editorial sections (TipTap)",
5
+ "license": "AGPL-3.0",
6
+ "type": "module",
7
+ "main": "./src/index.tsx",
8
+ "scripts": {
9
+ "lint": "echo \"no lint yet\""
10
+ },
11
+ "dependencies": {
12
+ "@portaki/module-sdk": "0.5.0",
13
+ "@portaki/sdk": "^0.5.0",
14
+ "@tiptap/core": "^3.0.0",
15
+ "@tiptap/html": "^3.0.0",
16
+ "@tiptap/starter-kit": "^3.0.0",
17
+ "react": "^19.1.0",
18
+ "react-dom": "^19.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/react": "^19",
22
+ "typescript": "^5"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/PortakiApp/portaki-modules.git",
27
+ "directory": "modules/sections"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,88 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/PortakiApp/portaki-sdk/main/schema/module.v1.json",
3
+ "id": "sections",
4
+ "name": {
5
+ "fr": "Sections éditoriales",
6
+ "en": "Editorial sections"
7
+ },
8
+ "description": {
9
+ "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
+ "en": "Rich content blocks (TipTap) for the guest welcome book."
11
+ },
12
+ "version": "1.0.0",
13
+ "author": {
14
+ "name": "Portaki",
15
+ "url": "https://portaki.app",
16
+ "type": "official"
17
+ },
18
+ "icon": "file-text",
19
+ "type": "official",
20
+ "tags": ["editorial", "content", "sections"],
21
+ "portakiVersionMin": "1.0.0",
22
+ "requiresHostSdk": "0.6.0",
23
+ "license": "AGPL-3.0",
24
+ "repository": "https://github.com/PortakiApp/portaki-modules",
25
+ "scopes": [
26
+ "stay:read",
27
+ "property:read",
28
+ "host:property:read",
29
+ "host:property:write"
30
+ ],
31
+ "queries": [
32
+ {
33
+ "name": "sections.list",
34
+ "description": {
35
+ "fr": "Liste des sections du logement.",
36
+ "en": "Property editorial sections."
37
+ },
38
+ "scope": "property:read"
39
+ }
40
+ ],
41
+ "commands": [
42
+ {
43
+ "name": "sections.section.save",
44
+ "description": {
45
+ "fr": "Créer ou mettre à jour une section.",
46
+ "en": "Create or update a section."
47
+ },
48
+ "scope": "host:property:write"
49
+ },
50
+ {
51
+ "name": "sections.section.delete",
52
+ "description": {
53
+ "fr": "Supprimer une section.",
54
+ "en": "Delete a section."
55
+ },
56
+ "scope": "host:property:write"
57
+ },
58
+ {
59
+ "name": "sections.reorder",
60
+ "description": {
61
+ "fr": "Réordonner les sections.",
62
+ "en": "Reorder sections."
63
+ },
64
+ "scope": "host:property:write"
65
+ }
66
+ ],
67
+ "hostSurfaces": [
68
+ {
69
+ "type": "property-workspace-tab",
70
+ "pathSegment": "sections",
71
+ "label": { "fr": "Sections", "en": "Sections" },
72
+ "icon": "file-text"
73
+ }
74
+ ],
75
+ "config": {
76
+ "defaults": {},
77
+ "fields": []
78
+ },
79
+ "catalog": {
80
+ "tagline": {
81
+ "fr": "Composez votre carnet section par section.",
82
+ "en": "Build your welcome book section by section."
83
+ },
84
+ "npmPackage": "@portaki/module-sections",
85
+ "npmUrl": "https://www.npmjs.com/package/@portaki/module-sections",
86
+ "javaArtifact": "app.portaki.module:sections-backend:0.1.0"
87
+ }
88
+ }
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import type { JSONContent } from '@tiptap/core'
4
+ import { generateHTML } from '@tiptap/html'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { usePortakiQuery } from '@portaki/sdk'
7
+ import type { LangCode } from '@portaki/module-sdk'
8
+ import { ModuleSection } from '@portaki/module-sdk'
9
+
10
+ type SectionRow = {
11
+ id: string
12
+ sortOrder: number
13
+ titleFr: string
14
+ titleEn: string
15
+ contentFr: unknown
16
+ contentEn: unknown
17
+ }
18
+
19
+ type ListResponse = {
20
+ sections: SectionRow[]
21
+ }
22
+
23
+ const htmlExtensions = [StarterKit]
24
+
25
+ function contentToHtml(raw: unknown): string {
26
+ if (raw == null) return ''
27
+ try {
28
+ const doc = (typeof raw === 'string' ? JSON.parse(raw) : raw) as JSONContent
29
+ return generateHTML(doc, htmlExtensions)
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
35
+ export function SectionsGuestView({ lang }: { lang: LangCode }) {
36
+ const { data, isLoading, isError } = usePortakiQuery<ListResponse>('sections.list', {})
37
+
38
+ if (isLoading) {
39
+ return (
40
+ <ModuleSection>
41
+ <p className="text-sm opacity-70">{lang === 'fr' ? 'Chargement…' : 'Loading…'}</p>
42
+ </ModuleSection>
43
+ )
44
+ }
45
+
46
+ if (isError || !data?.sections?.length) {
47
+ return null
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-10">
52
+ {data.sections.map((section) => {
53
+ const title = lang === 'en' ? section.titleEn || section.titleFr : section.titleFr
54
+ const html = contentToHtml(lang === 'en' ? section.contentEn : section.contentFr)
55
+ return (
56
+ <ModuleSection key={section.id} title={title}>
57
+ {html ? (
58
+ <div
59
+ className="portaki-sections-prose text-[15px] leading-relaxed [&_h1]:text-2xl [&_h2]:text-xl [&_h3]:text-lg [&_p]:my-2 [&_ul]:my-2 [&_ol]:my-2"
60
+ dangerouslySetInnerHTML={{ __html: html }}
61
+ />
62
+ ) : null}
63
+ </ModuleSection>
64
+ )
65
+ })}
66
+ </div>
67
+ )
68
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,17 @@
1
+ import type { ModuleContext } from '@portaki/module-sdk'
2
+ import { definePortakiModule } from '@portaki/module-sdk'
3
+
4
+ import { SectionsGuestView } from './components/SectionsGuestView'
5
+
6
+ export default definePortakiModule({
7
+ id: 'sections',
8
+ label: { fr: 'Sections', en: 'Sections' },
9
+ description: {
10
+ fr: 'Contenus éditoriaux du carnet d’accueil.',
11
+ en: 'Welcome book editorial content.',
12
+ },
13
+ version: '1.0.0',
14
+ icon: 'file-text',
15
+ navSlot: 'section',
16
+ render: ({ lang }: ModuleContext) => <SectionsGuestView lang={lang} />,
17
+ })