@live-change/frontend-template 0.9.201 → 0.9.204
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.
- package/.claude/rules/live-change-backend-actions-views-triggers.md +49 -1
- package/.claude/rules/live-change-backend-architecture.md +5 -5
- package/.claude/rules/live-change-backend-event-sourcing.md +2 -2
- package/.claude/rules/live-change-backend-models-and-relations.md +65 -8
- package/.claude/rules/live-change-frontend-e2e-lifecycle.md +42 -0
- package/.claude/rules/live-change-frontend-i18n-locales.md +25 -0
- package/.claude/rules/live-change-frontend-vue-primevue.md +100 -4
- package/.claude/rules/live-change-node-toolchain-fnm.md +39 -0
- package/.claude/rules/live-change-service-structure.md +2 -2
- package/.claude/settings.json +3 -1
- package/.claude/skills/create-skills-and-rules/SKILL.md +23 -0
- package/.claude/skills/live-change-backend-change-triggers/SKILL.md +15 -0
- package/.claude/skills/live-change-dao-protocol/SKILL.md +46 -0
- package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +116 -0
- package/.claude/skills/live-change-design-models-relations/SKILL.md +63 -5
- package/.claude/skills/live-change-frontend-command-forms/SKILL.md +6 -5
- package/.claude/skills/live-change-frontend-data-views/SKILL.md +75 -4
- package/.claude/skills/live-change-frontend-e2e-lifecycle/SKILL.md +82 -0
- package/.claude/skills/live-change-frontend-range-list/SKILL.md +90 -0
- package/.claude/skills/live-change-frontend-synchronized/SKILL.md +101 -0
- package/.claude/skills/live-change-node-toolchain-fnm/SKILL.md +44 -0
- package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +58 -4
- package/.cursor/rules/live-change-backend-architecture.mdc +1 -1
- package/.cursor/rules/live-change-backend-event-sourcing.mdc +1 -1
- package/.cursor/rules/live-change-backend-models-and-relations.mdc +69 -7
- package/.cursor/rules/live-change-backend-views-vs-triggers-for-reads-writes.mdc +28 -0
- package/.cursor/rules/live-change-dao-protocol.mdc +47 -0
- package/.cursor/rules/live-change-frontend-e2e-lifecycle.mdc +15 -0
- package/.cursor/rules/live-change-frontend-i18n-locales.mdc +26 -0
- package/.cursor/rules/live-change-frontend-views-not-commands-for-reads.mdc +30 -0
- package/.cursor/rules/live-change-frontend-vue-primevue.mdc +91 -0
- package/.cursor/rules/live-change-node-toolchain-fnm.mdc +40 -0
- package/.cursor/rules/live-change-service-structure.mdc +1 -1
- package/.cursor/skills/create-skills-and-rules.md +23 -0
- package/.cursor/skills/live-change-backend-change-triggers.md +15 -0
- package/.cursor/skills/live-change-design-actions-views-triggers.md +57 -0
- package/.cursor/skills/live-change-design-models-relations.md +23 -5
- package/.cursor/skills/live-change-frontend-command-forms.md +6 -5
- package/.cursor/skills/live-change-frontend-data-views.md +17 -0
- package/.cursor/skills/live-change-frontend-e2e-lifecycle.md +82 -0
- package/.cursor/skills/live-change-frontend-range-list.md +45 -0
- package/.cursor/skills/live-change-frontend-synchronized.md +101 -0
- package/.cursor/skills/live-change-node-toolchain-fnm.md +44 -0
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/README.md +18 -0
- package/e2e/client-session.test.ts +17 -0
- package/e2e/e2eSuite.ts +12 -0
- package/e2e/env.ts +130 -0
- package/e2e/homepage.test.ts +17 -0
- package/e2e/steps.ts +3 -0
- package/e2e/withBrowser.ts +18 -0
- package/opencode.json +4 -1
- package/package.json +55 -53
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-node-toolchain-fnm
|
|
3
|
+
description: Run node, npm, npx, tsx and framework CLI with fnm exec so .node-version and .nvmrc are respected
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: Node toolchain with fnm
|
|
7
|
+
|
|
8
|
+
Use this skill whenever you run **Node**, **npm**, **npx**, **tsx**, or **corepack** in this repo (tests, `describe`, dev servers, scripts). Agents must not use the default sandbox Node; use **fnm exec** so the version from dotfiles applies.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Running `npm test`, `npm run …`, linters, builds
|
|
13
|
+
- `node server/start.js describe` or any framework entry
|
|
14
|
+
- `tsx` for TypeScript scripts
|
|
15
|
+
- Any subprocess that would invoke `node` or `npm` for a project with `.node-version` / `.nvmrc`
|
|
16
|
+
|
|
17
|
+
## Step 1 – Find the right directory
|
|
18
|
+
|
|
19
|
+
Locate the nearest project root that has `.node-version` or `.nvmrc` for the task (often the app folder, e.g. `auto-firma/app/`).
|
|
20
|
+
|
|
21
|
+
`cd` to that directory before running commands so fnm reads the correct file.
|
|
22
|
+
|
|
23
|
+
## Step 2 – Prefix with fnm exec
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
fnm exec -- node server/start.js describe --service myService --output yaml
|
|
27
|
+
fnm exec -- npm test
|
|
28
|
+
fnm exec -- npx vitest
|
|
29
|
+
fnm exec -- tsx ./tools/something.ts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The part after `--` is the same command you would run manually with the correct Node active.
|
|
33
|
+
|
|
34
|
+
## Step 3 – Nested monorepo paths
|
|
35
|
+
|
|
36
|
+
If you are in the repo root but the dotfile is only under `some-app/`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd some-app && fnm exec -- npm test
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## If fnm is missing
|
|
43
|
+
|
|
44
|
+
Do not fall back to bare `node` / `npm` for framework work. Report that fnm is required (or document a one-off alternative the user approved).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Rules for implementing actions, views, and triggers in LiveChange services
|
|
3
|
-
globs: **/services/**/*.js
|
|
3
|
+
globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
|
|
4
4
|
alwaysApply: false
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -54,9 +54,9 @@ definition.action({
|
|
|
54
54
|
|
|
55
55
|
## Widoki – ogólne zasady
|
|
56
56
|
|
|
57
|
-
- Widok ma być
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
- Widok ma być **kanałem odczytu** (DAO, indeksy, agregacje, wyliczenia). Logika odczytu należy do widoku (ew. funkcji pomocniczej wywołanej z `daoPath` / `get`), a nie do akcji „tylko po to, żeby zwrócić dane bez zmiany stanu”.
|
|
58
|
+
- W implementacji ścieżek korzystaj z indeksów (`indexObjectGet`, `indexRangeGet`) zamiast pełnych skanów.
|
|
59
|
+
- Odczyt vs zapis (CQRS-like): [live-change-backend-views-vs-triggers-for-reads-writes.mdc](./live-change-backend-views-vs-triggers-for-reads-writes.mdc).
|
|
60
60
|
|
|
61
61
|
## Widoki – reguła: `get` + `observable` zawsze razem
|
|
62
62
|
|
|
@@ -138,6 +138,54 @@ definition.view({
|
|
|
138
138
|
})
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
### Widoki zakresowe: prefiksy indeksów i opcjonalne filtry
|
|
142
|
+
|
|
143
|
+
W `sortedIndexRangePath(indexName, keyPrefix, range)` najpierw działa `keyPrefix`, a dopiero potem `range` (`gt/gte/lt/lte`) na pełnym kluczu indeksu.
|
|
144
|
+
|
|
145
|
+
To oznacza:
|
|
146
|
+
|
|
147
|
+
- nie przekazuj surowych wartości pola (np. samej daty miesiąca) do `gt/lt`, jeśli klucz zaczyna się od innych części
|
|
148
|
+
- filtr domenowy (`month`, `state`, `company`) przekazuj jako osobny parametr widoku
|
|
149
|
+
- `range` zostaw do paginacji/cursora (RangeViewer i podobne mechanizmy)
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
definition.view({
|
|
153
|
+
name: 'bankTransactionsByBankAccountAndDate',
|
|
154
|
+
properties: {
|
|
155
|
+
bankAccount: { type: String },
|
|
156
|
+
month: { type: String },
|
|
157
|
+
...App.rangeProperties
|
|
158
|
+
},
|
|
159
|
+
async daoPath({ bankAccount, month, ...props }) {
|
|
160
|
+
const range = App.extractRange(props)
|
|
161
|
+
if(month) {
|
|
162
|
+
const prefix = [bankAccount, month].map(v => JSON.stringify(v)).join(':')
|
|
163
|
+
return BankTransaction.rangePath(App.utils.prefixRange(range, prefix, prefix + ':'))
|
|
164
|
+
}
|
|
165
|
+
return BankTransaction.sortedIndexRangePath('byBankAccountAndDate', [bankAccount], range)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Jeśli filtr po miesiącu jest częsty, lepiej dodać indeks z bucketem miesiąca (`byBankAccountAndMonthAndDate`) i użyć:
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
BankTransaction.sortedIndexRangePath('byBankAccountAndMonthAndDate', [bankAccount, month], range)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Guardrails dla widoków pod RangeViewer/rangeBuckets
|
|
177
|
+
|
|
178
|
+
- Dla list paginowanych po indeksie preferuj `Model.sortedIndexRangePath(indexName, keyPrefix, App.extractRange(props))`.
|
|
179
|
+
- Nie stosuj semantyki `indexRangePath` dla widoków konsumowanych przez bucketowe UI zakresowe.
|
|
180
|
+
- `gt/gte/lt/lte` zostaw wyłącznie jako kursor paginacji, nie jako filtry domenowe.
|
|
181
|
+
- Dla filtrów typu miesiąc/rok/status najpierw projektuj indeks z odpowiednim prefiksem.
|
|
182
|
+
- `App.utils.prefixRange` traktuj jako fallback backendowy, gdy zmiana indeksu nie jest możliwa.
|
|
183
|
+
|
|
184
|
+
### Guardrail dla indeksów standalone
|
|
185
|
+
|
|
186
|
+
- Gdy indeks łączy równorzędne strumienie danych (union wielu tabel), definiuj go jako serwisowy `definition.index(...)` (najlepiej w osobnym `indexes.js`), a nie jako `model.indexes` przypisany do przypadkowego modelu.
|
|
187
|
+
- `model.indexes` stosuj tylko wtedy, gdy semantycznym właścicielem indeksu jest jeden model.
|
|
188
|
+
|
|
141
189
|
## Triggery – online/offline i batchowanie
|
|
142
190
|
|
|
143
191
|
- Triggery są do reakcji na zdarzenia (np. zmiana stanu sesji, start serwera).
|
|
@@ -236,6 +284,12 @@ definition.trigger({
|
|
|
236
284
|
|
|
237
285
|
Sprawdzaj `data`/`oldData`: oba obecne = update, tylko `data` = create, tylko `oldData` = delete.
|
|
238
286
|
|
|
287
|
+
## Cron-service — harmonogramy, interwały i UI admina
|
|
288
|
+
|
|
289
|
+
- Dla wykonywania **triggerów** wg **czasu ściennego** lub **stałego interwału** używaj **`@live-change/cron-service`** (**Schedule** / **Interval**) wraz z **task-service** — nie planuj „tylko timera” bez modeli cron i cyklu życia **`changeCron_Schedule`** / **`changeCron_Interval`**.
|
|
290
|
+
- Wzorzec admina (jak **task-frontend**): **`setSchedule`** / **`setInterval`** przez **`ActionForm`**, listy przez **`path.cron.schedules`** / **`path.cron.intervals`**, wzbogacanie wierszy **`.with()`** o **`scheduleInfo`** / **`intervalInfo`**, **`runState`** (`jobType` **`cron_Schedule`** lub **`cron_Interval`**), **`task.tasksByCauseAndCreatedAt`**; usuwanie przez **`deleteSchedule`** / **`deleteInterval`**.
|
|
291
|
+
- Pola czasu **Schedule** (**minute**, **hour**, **day**, **dayOfWeek**, **month**): **`NaN`** = „każdy” na danym poziomie — szczegóły w **`15-cron-and-intervals.md`** (sekcja **API used by task-frontend**).
|
|
292
|
+
|
|
239
293
|
## Wzorzec „pending + resolve” (asynchroniczny wynik)
|
|
240
294
|
|
|
241
295
|
- Używaj, gdy akcja w serwisie musi poczekać na wynik z zewnętrznego procesu (np. urządzenie, worker).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Rules for defining models, relations, indexes and access control in LiveChange
|
|
3
|
-
globs: **/services/**/*.js
|
|
3
|
+
globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
|
|
4
4
|
alwaysApply: false
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -36,6 +36,29 @@ properties: {
|
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
## Arity relacji (bardzo ważne)
|
|
40
|
+
|
|
41
|
+
Rozróżniaj dwa poziomy:
|
|
42
|
+
|
|
43
|
+
- **arity adnotacji** — czy można podać listę konfiguracji
|
|
44
|
+
- **arity rodziców w konfiguracji** — `what: [A, B]` lub `to: ['owner', 'topic']`
|
|
45
|
+
|
|
46
|
+
| Relacja | Arity adnotacji | Arity rodziców |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `propertyOf` | jedna konfiguracja | `what` może być pojedyncze lub tablica modeli |
|
|
49
|
+
| `itemOf` | jedna konfiguracja | `what` może być pojedyncze lub tablica modeli |
|
|
50
|
+
| `boundTo` | jedna konfiguracja | `what` może być pojedyncze lub tablica modeli |
|
|
51
|
+
| `relatedTo` | jedna lub wiele konfiguracji | w każdej konfiguracji `what` może być pojedyncze lub tablica modeli |
|
|
52
|
+
| `propertyOfAny` | jedna konfiguracja | `to` może mieć jedną lub wiele nazw |
|
|
53
|
+
| `itemOfAny` | jedna konfiguracja | `to` może mieć jedną lub wiele nazw |
|
|
54
|
+
| `boundToAny` | jedna konfiguracja | `to` może mieć jedną lub wiele nazw |
|
|
55
|
+
| `relatedToAny` | jedna lub wiele konfiguracji | w każdej konfiguracji `to` może mieć jedną lub wiele nazw |
|
|
56
|
+
|
|
57
|
+
Przykłady:
|
|
58
|
+
|
|
59
|
+
- poprawnie: `propertyOf: { what: [A, B] }`
|
|
60
|
+
- niepoprawnie: `propertyOf: [configA, configB]`
|
|
61
|
+
|
|
39
62
|
## `userItem` – zasób należący do użytkownika
|
|
40
63
|
|
|
41
64
|
Używaj, gdy model należy do zalogowanego użytkownika.
|
|
@@ -123,7 +146,7 @@ Wtedy:
|
|
|
123
146
|
|
|
124
147
|
- **nie** przechowuj „drugiej strony” jako zwykłego `contractorId` (albo ogólnie `someId`) w `properties`
|
|
125
148
|
- **nie** dodawaj ręcznie pól `...Id` w modelu relacyjnym, jeśli to ma być relacja – to utrudnia generatorowi CRUD/relacji poprawne wnioskowanie o powiązaniach
|
|
126
|
-
- zamiast tego zdefiniuj
|
|
149
|
+
- zamiast tego zdefiniuj jedną relację `propertyOf` z `what: [ModelA, ModelB, ...]`
|
|
127
150
|
|
|
128
151
|
To jest istotne, bo generator CRUD/relacji rozumie wtedy, że encja jest powiązaniem pomiędzy dwiema encjami, a nie „zwykłym obiektem z polem id”.
|
|
129
152
|
|
|
@@ -138,10 +161,9 @@ definition.model({
|
|
|
138
161
|
properties: {
|
|
139
162
|
// dodatkowe pola relacji (opcjonalnie)
|
|
140
163
|
},
|
|
141
|
-
propertyOf:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
]
|
|
164
|
+
propertyOf: {
|
|
165
|
+
what: [CostInvoice, Contractor]
|
|
166
|
+
}
|
|
145
167
|
})
|
|
146
168
|
```
|
|
147
169
|
|
|
@@ -186,7 +208,7 @@ Relacje automatycznie dodają **pola identyfikatorów** i **indeksy** do modelu.
|
|
|
186
208
|
| `propertyOfAny: { ownerTypes: [...] }` | `ownerType`, `owner` | `byOwner` (hash) |
|
|
187
209
|
| `boundTo: { what: Device }` | `device` | `byDevice` (hash) |
|
|
188
210
|
|
|
189
|
-
Dla relacji z wieloma rodzicami (np. `propertyOf:
|
|
211
|
+
Dla relacji z wieloma rodzicami (np. `propertyOf: { what: [A, B] }`) tworzone są wszystkie kombinacje indeksów (`byA`, `byB`, `byAAndB`).
|
|
190
212
|
|
|
191
213
|
## `propertyOfAny` — typy rodzica (`{name}Types`)
|
|
192
214
|
|
|
@@ -249,6 +271,46 @@ indexes: {
|
|
|
249
271
|
|
|
250
272
|
Poza serwisem indeks bywa widoczny pod nazwą z prefiksem serwisu, np. `myService_Model_byDeviceAndStatus`.
|
|
251
273
|
|
|
274
|
+
### Kiedy indeks ma być poza modelem
|
|
275
|
+
|
|
276
|
+
Jeśli indeks jest unią/projekcją danych z kilku równorzędnych tabel (bez jednego naturalnego właściciela), nie wciskaj go do `model.indexes`.
|
|
277
|
+
|
|
278
|
+
- Użyj serwisowego `definition.index(...)`, najlepiej w osobnym `indexes.js`.
|
|
279
|
+
- To dotyczy szczególnie indeksów łączących różne typy encji w jeden strumień odczytu.
|
|
280
|
+
|
|
281
|
+
### Indeksy `function` dla pól wyliczanych
|
|
282
|
+
|
|
283
|
+
Gdy część klucza indeksu nie istnieje jako zwykłe pole modelu (np. `month` wyliczany z `date`), użyj indeksu `function` zamiast `property`.
|
|
284
|
+
|
|
285
|
+
Zasady:
|
|
286
|
+
|
|
287
|
+
- buduj stabilny klucz jako `JSON.stringify(part1):JSON.stringify(part2):... + '_' + id`
|
|
288
|
+
- zwracaj obiekty `{ id, to }`, gdzie `to` wskazuje źródłowy rekord
|
|
289
|
+
- preferuj `table.map(mapper).to(output)` zamiast ręcznego `.onChange(...output.change...)`
|
|
290
|
+
- `map()` automatycznie odfiltrowuje `null`, więc mapper może zwracać tylko docelowy obiekt
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
indexes: {
|
|
294
|
+
byBankAccountAndMonthAndDate: {
|
|
295
|
+
function: async (input, output, { tableName }) => {
|
|
296
|
+
const table = await input.table(tableName)
|
|
297
|
+
const mapper = obj => ({
|
|
298
|
+
id: [
|
|
299
|
+
obj.bankAccount,
|
|
300
|
+
obj.date?.slice(0, 7),
|
|
301
|
+
obj.date
|
|
302
|
+
].map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
|
|
303
|
+
to: obj.id
|
|
304
|
+
})
|
|
305
|
+
await table.map(mapper).to(output)
|
|
306
|
+
},
|
|
307
|
+
parameters: {
|
|
308
|
+
tableName: definition.name + '_BankTransaction'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
252
314
|
## Access control na relacjach
|
|
253
315
|
|
|
254
316
|
- Zawsze ustawiaj `readAccessControl` i `writeAccessControl` na relacjach (`userItem`, `itemOf`, `propertyOf`), zamiast polegać na domyślnym zachowaniu.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: LiveChange backend — views/viewGet for reads; actions/triggers + emit for writes (CQRS-like)
|
|
3
|
+
globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# LiveChange backend — widoki do odczytu, akcja/trigger do zapisu
|
|
8
|
+
|
|
9
|
+
Na serwerze **inne ścieżki niż na froncie**: nie ma `live` / `useFetch` — odczyt definiujesz jako **`definition.view`** i ewentualnie wywołujesz go z kodu przez **`app.viewGet`** / **`app.serviceViewGet`**.
|
|
10
|
+
|
|
11
|
+
## Odczyt
|
|
12
|
+
|
|
13
|
+
- **Widok** (`definition.view`) — jedyny kanał wystawienia danych do klienta (DAO, indeksy, wyliczenia, `fetch` zewnętrzny). Może zwracać dane złożone lub wyliczone (np. podgląd „następnego numeru”).
|
|
14
|
+
- **Z kodu serwera** (trigger, akcja, batch):
|
|
15
|
+
- `await app.viewGet('viewName', { ...properties })` — widok **w tym samym** serwisie co `app`.
|
|
16
|
+
- `await app.serviceViewGet('otherService', 'viewName', { ...properties })` — widok w **innym** serwisie.
|
|
17
|
+
- Bezpośredni odczyt modelu (`Model.get`, `indexObjectGet`, …) jest OK tam, gdzie nie potrzebujesz warstwy widoku/access control widoku — ale **nie** zamieniaj tego na „akcję tylko po to, żeby coś zwrócić”.
|
|
18
|
+
|
|
19
|
+
## Zapis / zmiana stanu
|
|
20
|
+
|
|
21
|
+
- **Akcja** (`definition.action`) i **trigger** (`definition.trigger`) — walidacja, orchestracja, **`emit`** do eventów, **`trigger`** / **`triggerService`** do zapisu zgodnie z [live-change-backend-event-sourcing.mdc](./live-change-backend-event-sourcing.mdc).
|
|
22
|
+
- Nie dodawaj `definition.action`, której jedynym celem jest zwrócenie danych **bez** trwałej zmiany stanu — użyj **widoku**.
|
|
23
|
+
|
|
24
|
+
## Antywzorzec
|
|
25
|
+
|
|
26
|
+
- Akcja/command po stronie klienta tylko po to, żeby pobrać podgląd — błąd projektu po obu stronach: na backendzie powinien być **widok**, na froncie `live`/`useFetch` (reguła frontendowa).
|
|
27
|
+
|
|
28
|
+
Szczegóły: `live-change-stack/docs/docs/server/07-views.md`, `06-actions.md`, `08-triggers.md`. API klienta (Vue) — `live-change-stack/docs/docs/frontend/04-logic-and-data-layer.md`, nie ta reguła.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Zasady komunikacji przez surowy protokół DAO (C++, Python, inne klienty)
|
|
3
|
+
globs: *.cpp, *.py, *.rs, *.go
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LiveChange DAO Protocol Arguments
|
|
7
|
+
|
|
8
|
+
When communicating with the LiveChange framework using the raw `@live-change/dao` protocol (e.g., from C++, Python, Rust, Go, or any other non-JS client), you MUST ALWAYS pass arguments as an **array**.
|
|
9
|
+
|
|
10
|
+
The framework treats DAO request and observable arguments like function arguments and uses the spread operator (`...args`) to pass them to the underlying action or view functions. If you pass an object instead of an array, the server will throw a `TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function`.
|
|
11
|
+
|
|
12
|
+
Even if an action or view expects a single object as its parameter, that object MUST be wrapped in a single-element array.
|
|
13
|
+
|
|
14
|
+
## C++ Example (using nlohmann/json)
|
|
15
|
+
|
|
16
|
+
### Incorrect ❌
|
|
17
|
+
```cpp
|
|
18
|
+
nlohmann::json args = {
|
|
19
|
+
{"pairingKey", "123"},
|
|
20
|
+
{"connectionType", "device"}
|
|
21
|
+
};
|
|
22
|
+
connection->request({"serviceName", "actionName"}, args, settings);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Correct ✅
|
|
26
|
+
```cpp
|
|
27
|
+
// Wrap the object in an array
|
|
28
|
+
auto args = {
|
|
29
|
+
nlohmann::json::object({
|
|
30
|
+
{"pairingKey", "123"},
|
|
31
|
+
{"connectionType", "device"}
|
|
32
|
+
})
|
|
33
|
+
};
|
|
34
|
+
connection->request({"serviceName", "actionName"}, args, settings);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or explicitly:
|
|
38
|
+
```cpp
|
|
39
|
+
nlohmann::json args = nlohmann::json::array({
|
|
40
|
+
nlohmann::json::object({
|
|
41
|
+
{"pairingKey", "123"},
|
|
42
|
+
{"connectionType", "device"}
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Always double-check that your `args` payload is an array before sending it over the DAO connection.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Stable node:test E2E lifecycle with env, withBrowser and e2eSuite
|
|
3
|
+
globs: **/e2e/**/*.{js,ts}
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Frontend E2E lifecycle
|
|
8
|
+
|
|
9
|
+
- Keep teardown out of shared `e2e/env.ts`.
|
|
10
|
+
- Never use `after(...process.exit(...))` in `env.ts`.
|
|
11
|
+
- Add `disposeTestEnv()` in `env.ts` and call it from `process.on('beforeExit', ...)`.
|
|
12
|
+
- Put `after(async () => { await disposeTestEnv(); process.exit(0) })` in `e2eSuite.ts`.
|
|
13
|
+
- Wrap each `e2e/*.test.ts` file in one `e2eSuite('<suite-name>', () => { ... })`.
|
|
14
|
+
- Keep startup failure `process.exit(1)` in `getTestEnv()` catch.
|
|
15
|
+
- Run test commands with `fnm exec -- ...`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Keep all locale files under front/locales in sync when adding or changing i18n strings
|
|
3
|
+
globs: **/front/locales/**/*.{json,js,ts}, **/front/src/**/*.{vue,js,ts}
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Frontend i18n – every file in `front/locales`
|
|
8
|
+
|
|
9
|
+
When you add or change **user-visible strings** (labels, messages, buttons, errors, page titles, etc.) that go through **vue-i18n** or the project’s locale files, you must update **every** locale resource in that app’s **`front/locales/`** directory — not only one language.
|
|
10
|
+
|
|
11
|
+
## Required workflow
|
|
12
|
+
|
|
13
|
+
1. **Identify the app root** for the frontend you are editing (the folder whose tree contains `front/src` and `front/locales`).
|
|
14
|
+
2. **List all locale files** in `front/locales/` for that app (`en.json`, `pl.json`, `en.js`, `pl.js`, `landing-en.json`, … — whatever exists).
|
|
15
|
+
3. **Add or update the same keys** in **each** of those files. Structure and nesting must stay consistent across languages (same key paths).
|
|
16
|
+
4. **Do not** add a new key to a single locale file and leave others missing — that breaks builds or shows raw keys at runtime.
|
|
17
|
+
|
|
18
|
+
## Translations you are unsure about
|
|
19
|
+
|
|
20
|
+
- Prefer a correct string in the primary locales (e.g. `en` + `pl`) when the product supports them.
|
|
21
|
+
- For other files, use a sensible placeholder, copy from English, or add a clearly marked temporary string — but **still add the key** everywhere so nothing is omitted.
|
|
22
|
+
|
|
23
|
+
## Scope
|
|
24
|
+
|
|
25
|
+
- Applies to any work under `front/src` that introduces or changes translated text, and to direct edits in `front/locales`.
|
|
26
|
+
- If a project uses multiple locale formats (`.json` and `.js`), update **all** files that participate in i18n for that app, not only one extension.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: LiveChange frontend — use views (live/useFetch) for reads; commands only for mutations
|
|
3
|
+
globs: **/front/**/*.{vue,js,ts}
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# LiveChange frontend — odczyt przez widoki, nie przez command
|
|
8
|
+
|
|
9
|
+
Ten sam **model mentalny** co na backendzie (odczyt vs zapis), ale **inne API**: na froncie nie ma `app.viewGet` — klient korzysta ze **ścieżek widoków** z `usePath()`.
|
|
10
|
+
|
|
11
|
+
## Odczyt danych
|
|
12
|
+
|
|
13
|
+
- Buduj ścieżkę: `usePath()` (tylko synchronicznie w `setup`) → `path.<service>.<viewName>({ ...params })`; w `computed` używaj już utworzonego obiektu `path`, nie wywołuj ponownie `usePath()` / `path()`.
|
|
14
|
+
- Subskrypcja: `live(computed(() => path...))` albo `await Promise.all([live(...), ...])`.
|
|
15
|
+
- Jednorazowo: `useFetch(path.service.view({ ... }))` (np. w handlerze, po uploadzie).
|
|
16
|
+
- Dla niezależnych odczytów uruchamiaj `live(...)` równolegle (`Promise.all`), a sekwencyjnie tylko gdy drugi path zależy od wyniku pierwszego.
|
|
17
|
+
- W `Path.with(...)` buduj deklaratywnie ścieżki; do branchingu po typie/polu używaj `$switch`, nie runtime `if` na callbacku `.with`.
|
|
18
|
+
|
|
19
|
+
Nie używaj `api.command` ani `useActions()` do „załadowania” danych, podglądu, wyliczenia tylko do UI, ani „jakiego będzie następny numer” — to **zawsze** powinno iść przez **widok** (`definition.view` po stronie serwera) i `live` / `useFetch` po stronie klienta.
|
|
20
|
+
|
|
21
|
+
## Mutacja stanu
|
|
22
|
+
|
|
23
|
+
- `api.command(['service', 'actionName'], props)` lub `useActions()` → wywołanie **akcji** zmieniającej trwały stan (create/update/delete przez eventy itd.).
|
|
24
|
+
- `workingZone` i spinner przy commandach mają sens przy **zapisie**, nie przy odczycie.
|
|
25
|
+
|
|
26
|
+
## Antywzorzec
|
|
27
|
+
|
|
28
|
+
- Wywołanie akcji/command tylko po to, żeby dostać zwrócony string (np. podgląd numeru faktury) **bez** zmiany bazy — **źle**. Zamiast tego dodaj **widok** zwracający ten podgląd i pobierz go przez `live` / `useFetch`.
|
|
29
|
+
|
|
30
|
+
Szczegóły API: `live-change-stack/docs/docs/frontend/04-logic-and-data-layer.md`. Backend (`app.viewGet`, triggery): reguła `live-change-backend-views-vs-triggers-for-reads-writes.mdc` — nie stosuj jej w plikach Vue.
|
|
@@ -19,6 +19,8 @@ alwaysApply: false
|
|
|
19
19
|
- **Nie** używaj `ref(null)` + `onMounted` do ładowania danych.
|
|
20
20
|
- Dane ładuj w `setup`/`script setup` przez `await Promise.all(...)` na `live(path()....)`.
|
|
21
21
|
- Rodzic powinien owijać stronę w `<Suspense>` (w projektach live-change robi to zazwyczaj `ViewRoot` globalnie).
|
|
22
|
+
- Sekwencyjnego `await live(...)` używaj tylko wtedy, gdy drugi path zależy od wyniku pierwszego.
|
|
23
|
+
- Gdy zależność da się opisać przez `.with(...)`, preferuj jedno zapytanie Path DSL zamiast imperatywnego łączenia wyników.
|
|
22
24
|
|
|
23
25
|
Przykład:
|
|
24
26
|
|
|
@@ -64,6 +66,50 @@ Schemat decyzji:
|
|
|
64
66
|
2. Edytuje rekord modelu (create/update)? → **Tak**: użyj `editorData`. **Nie**: użyj `actionData`.
|
|
65
67
|
3. `<command-form>` tylko do najprostszych jednorazowych przypadków.
|
|
66
68
|
|
|
69
|
+
## Autosave helpery – `synchronized` i `synchronizedList`
|
|
70
|
+
|
|
71
|
+
Używaj helperów dla danych z `live(...)`:
|
|
72
|
+
|
|
73
|
+
- `synchronized` dla pojedynczego obiektu do edycji.
|
|
74
|
+
- `synchronizedList` dla edytowalnych list (wierszy).
|
|
75
|
+
- Kontekst wspólny przekazuj przez `identifiers`, a identyfikatory wiersza przez `objectIdentifiers`.
|
|
76
|
+
- Dla draftów z payloadem zagnieżdżonym używaj `updateDataProperty: 'data'`.
|
|
77
|
+
|
|
78
|
+
Kiedy traktować ekran jako listę:
|
|
79
|
+
|
|
80
|
+
- UI ma `v-for` z wieloma edytowalnymi wierszami (tabela/lista konfiguracji/admin).
|
|
81
|
+
- Użytkownik edytuje wiele rekordów inline, a zapis ma działać per wiersz.
|
|
82
|
+
- Akcje backendowe potrzebują wspólnego kontekstu listy i kluczy konkretnego wiersza.
|
|
83
|
+
|
|
84
|
+
W takich przypadkach stosuj jeden `synchronizedList(...)` i edytuj dane bezpośrednio przez `syncList.value`.
|
|
85
|
+
Dla takich list nie buduj osobnej mapy `id -> synchronized(...)`.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const sync = synchronized({
|
|
89
|
+
source: sourceRef,
|
|
90
|
+
update: actions.service.updateThing,
|
|
91
|
+
identifiers: { thing: thingId },
|
|
92
|
+
recursive: true,
|
|
93
|
+
autoSave: true,
|
|
94
|
+
debounce: 600
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const syncList = synchronizedList({
|
|
98
|
+
source: rowsRef,
|
|
99
|
+
update: actions.service.updateRow,
|
|
100
|
+
delete: actions.service.deleteRow,
|
|
101
|
+
identifiers: { object, objectType },
|
|
102
|
+
objectIdentifiers: row => ({ row: row.to, object, objectType }),
|
|
103
|
+
recursive: true
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Przykłady ekranów, gdzie to dotyczy:
|
|
108
|
+
|
|
109
|
+
- listy uprawnień i ról,
|
|
110
|
+
- słowniki i konfiguracje wielowierszowe,
|
|
111
|
+
- tabele administracyjne z inline edycją pól.
|
|
112
|
+
|
|
67
113
|
### `api.command`
|
|
68
114
|
|
|
69
115
|
```js
|
|
@@ -183,6 +229,8 @@ const articlePath = computed(() => path.blog.article({ article: unref(articleId)
|
|
|
183
229
|
const [article] = await Promise.all([live(articlePath)])
|
|
184
230
|
```
|
|
185
231
|
|
|
232
|
+
`usePath()` wywołuj synchronicznie w `setup` i zapisz wynik w zmiennej `path`. W getterze `computed` buduj tylko `path.<service>.<view>(...)` — nie wywołuj ponownie `usePath()` ani aliasu `path()` w środku `computed` (brak aktywnej instancji → błąd przy `appContext`).
|
|
233
|
+
|
|
186
234
|
Dla warunkowego ładowania (np. tylko gdy zalogowany), zwróć wartość falsy:
|
|
187
235
|
|
|
188
236
|
```js
|
|
@@ -207,6 +255,49 @@ path.blog.articles({})
|
|
|
207
255
|
|
|
208
256
|
Dostęp: `article.authorProfile?.firstName`. Działa zarówno z `live()` jak i `RangeViewer`.
|
|
209
257
|
|
|
258
|
+
### Guardrails Path DSL (`.with`, `$switch`)
|
|
259
|
+
|
|
260
|
+
- Callback `.with(item => ...)` traktuj jako deklarację query DSL, nie jako kod biznesowy wykonywany na realnym rekordzie.
|
|
261
|
+
- W callbacku `.with` nie rób side effects, `api.command`, ani imperatywnego `if/else` porównującego pola proxy.
|
|
262
|
+
- Dla warunkowego wyboru ścieżki używaj `item.field.$switch({...}).$bind('target')`.
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
path.accounting.settlementsByTransaction({ transactionType, transaction, range })
|
|
266
|
+
.with(settlement => settlement.subjectType.$switch({
|
|
267
|
+
invoice_CostInvoice: path.invoice.costInvoice({ costInvoice: settlement.subject }),
|
|
268
|
+
invoice_IncomeInvoice: path.invoice.incomeInvoice({ incomeInvoice: settlement.subject }),
|
|
269
|
+
hr_CivilContract: path.hr.civilContract({ civilContract: settlement.subject })
|
|
270
|
+
}).$bind('subjectDoc'))
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Listy zakresowe z reaktywnymi filtrami
|
|
274
|
+
|
|
275
|
+
Jeśli `pathFunction` dla listy zakresowej zależy od reaktywnych filtrów (np. miesiąc/status/szukaj), preferuj `ReactiveRangeViewer`.
|
|
276
|
+
|
|
277
|
+
Zasady:
|
|
278
|
+
|
|
279
|
+
- nie polegaj na samej zmianie `pathFunction` w `RangeViewer`
|
|
280
|
+
- nie rozrzucaj workaroundów typu ręczne `:key` po stronach
|
|
281
|
+
- użyj `sourceKey` jako jawnego triggera przeładowania
|
|
282
|
+
- gdy UX wymaga stabilności układu, ustaw `preserveHeightOnReload`
|
|
283
|
+
|
|
284
|
+
```vue
|
|
285
|
+
<ReactiveRangeViewer
|
|
286
|
+
:pathFunction="transactionsPathRange"
|
|
287
|
+
:sourceKey="JSON.stringify({ accountId, month: filterByMonth ? month : null })"
|
|
288
|
+
:preserveHeightOnReload="true"
|
|
289
|
+
:canLoadTop="false"
|
|
290
|
+
canDropBottom
|
|
291
|
+
/>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Guardrails dla kursora zakresu (`RangeViewer` / `rangeBuckets`)
|
|
295
|
+
|
|
296
|
+
- Dla list opartych o indeks backend powinien udostępniać widoki oparte o `sortedIndexRangePath`.
|
|
297
|
+
- Nigdy nie nadpisuj `range.gt/gte/lt/lte` we frontendowym `pathFunction`.
|
|
298
|
+
- Pole `range` zostaw jako kursor paginacji; filtry domenowe (`month`, `year`, `status`) przekazuj osobno.
|
|
299
|
+
- Jeśli pojawia się pokusa ręcznego przepisywania kursora, przenieś logikę do projektu indeksu po stronie backendu (ew. fallback `prefixRange`), nie do hacków we froncie.
|
|
300
|
+
|
|
210
301
|
## WorkingZone dla akcji asynchronicznych
|
|
211
302
|
|
|
212
303
|
`ViewRoot` opakowuje każdą stronę w `<WorkingZone>`. Używaj `inject('workingZone')` dla akcji przycisków poza formularzami:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run Node, npm, npx, tsx with fnm exec using project .node-version or .nvmrc
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Node.js toolchain – use `fnm exec`
|
|
7
|
+
|
|
8
|
+
Do **not** rely on whatever Node.js version the agent sandbox or shell happens to use. That often differs from the project and breaks the LiveChange stack, tests, and `describe`.
|
|
9
|
+
|
|
10
|
+
Use **fnm** so the version from the project dotfiles (`.node-version` or `.nvmrc`) is selected.
|
|
11
|
+
|
|
12
|
+
## Required pattern
|
|
13
|
+
|
|
14
|
+
Work in the directory that contains the relevant `.node-version` or `.nvmrc` (app or package root). Prefix **every** `node`, `npm`, `npx`, `corepack`, or `tsx` invocation with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
fnm exec -- <command and arguments>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
fnm exec -- node server/start.js describe --service myService --output yaml
|
|
24
|
+
fnm exec -- npm test
|
|
25
|
+
fnm exec -- npm run build
|
|
26
|
+
fnm exec -- npx eslint .
|
|
27
|
+
fnm exec -- tsx scripts/example.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If the dotfile lives only under a subfolder (e.g. `auto-firma/app/`), `cd` there first, then run `fnm exec -- ...`.
|
|
31
|
+
|
|
32
|
+
## When this applies
|
|
33
|
+
|
|
34
|
+
- Tests, CI scripts, linters, builds
|
|
35
|
+
- Framework CLI: `describe`, dev servers, migrations, anything touching `@live-change/*`
|
|
36
|
+
- Any `npm` / `node` / `npx` / `tsx` for this monorepo or its subprojects
|
|
37
|
+
|
|
38
|
+
## If `fnm` is unavailable
|
|
39
|
+
|
|
40
|
+
Tell the user to install fnm or align the environment; do not proceed assuming an arbitrary `node` on `PATH` is correct.
|
|
@@ -137,6 +137,22 @@ globs: **/front/src/**/*.{vue,js,ts}
|
|
|
137
137
|
| `description` | What this rule covers (used for matching) |
|
|
138
138
|
| `globs` | File patterns that trigger this rule (e.g. `**/*.js`, `**/services/**/*.js`) |
|
|
139
139
|
|
|
140
|
+
### Backend / LiveChange service rules — standard `globs`
|
|
141
|
+
|
|
142
|
+
Rules for **server-side** LiveChange code (models, actions, triggers, service layout) should use a **comma-separated** `globs` line so they still match when a project stores services under different folder names (many teams copy `.cursor/rules` into other repos):
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Pattern | Covers |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `**/services/**/*.js` | Trees such as `live-change-stack/services/<service>/` |
|
|
151
|
+
| `**/server/**/*.js` | Any nested `server/` directory (e.g. `app/server/`, `packages/foo/server/`) |
|
|
152
|
+
| `server/**/*.js` | Backend rooted at top-level `server/` |
|
|
153
|
+
|
|
154
|
+
Frontend rules keep their own globs (e.g. `**/front/src/**/*.{vue,js,ts}`). Do not drop the `server` variants for backend-only rules — otherwise Cursor will miss files after a layout change.
|
|
155
|
+
|
|
140
156
|
### Body structure
|
|
141
157
|
|
|
142
158
|
```markdown
|
|
@@ -181,6 +197,7 @@ alwaysApply: false
|
|
|
181
197
|
- Do NOT quote glob patterns in frontmatter
|
|
182
198
|
- Keep rules short (target 25 lines, max 50 lines for best Cursor performance)
|
|
183
199
|
- The `.mdc` extension is required for Cursor
|
|
200
|
+
- For **backend** LiveChange rules, use the same **`globs`** standard as in Claude rules: `**/services/**/*.js, **/server/**/*.js, server/**/*.js`
|
|
184
201
|
|
|
185
202
|
## Step 5 – Register rules in OpenCode (`opencode.json`)
|
|
186
203
|
|
|
@@ -198,8 +215,12 @@ OpenCode reads `.claude/skills/<name>/SKILL.md` natively for skills (no extra st
|
|
|
198
215
|
|
|
199
216
|
When you **create a new rule**, add its path to the `instructions` array in `opencode.json`.
|
|
200
217
|
|
|
218
|
+
Project rule **`live-change-node-toolchain-fnm`** should stay **first** (or early) in `instructions` so agents always load the requirement to run `node`, `npm`, `npx`, and `tsx` via `fnm exec` using `.node-version` / `.nvmrc`.
|
|
219
|
+
|
|
201
220
|
When you **create a new skill**, no `opencode.json` change is needed — OpenCode discovers skills from `.claude/skills/<name>/SKILL.md` automatically.
|
|
202
221
|
|
|
222
|
+
When a skill or rule shows shell examples that invoke **`node`**, **`npm`**, **`npx`**, or **`tsx`**, use the **`fnm exec -- …`** form (see `.claude/rules/live-change-node-toolchain-fnm.md` and skill `live-change-node-toolchain-fnm`).
|
|
223
|
+
|
|
203
224
|
**Important:** OpenCode ignores the `globs` frontmatter from Claude Code rules. All instructions listed in `opencode.json` are always loaded.
|
|
204
225
|
|
|
205
226
|
## Step 6 – Mirror Cursor skills (`.cursor/skills/<name>.md`)
|
|
@@ -239,10 +260,12 @@ done
|
|
|
239
260
|
|
|
240
261
|
## Checklist
|
|
241
262
|
|
|
263
|
+
- [ ] Shell examples for Node/npm use `fnm exec --` per `live-change-node-toolchain-fnm`
|
|
242
264
|
- [ ] Directory created: `.claude/skills/<name>/SKILL.md`
|
|
243
265
|
- [ ] Frontmatter has both `name` (matching dir) and `description`
|
|
244
266
|
- [ ] `.cursor/skills/<name>.md` mirrored (flat file, same content)
|
|
245
267
|
- [ ] `.claude/rules/*.md` created (if rule)
|
|
246
268
|
- [ ] `.cursor/rules/*.mdc` created with `globs` + `alwaysApply` (if rule)
|
|
269
|
+
- [ ] Backend rules use standard `globs`: `**/services/**/*.js, **/server/**/*.js, server/**/*.js` (when the rule targets LiveChange server code)
|
|
247
270
|
- [ ] `opencode.json` `instructions` array updated (if new rule)
|
|
248
271
|
- [ ] Sub-projects updated (automation, auto-firma)
|