@lenne.tech/nest-server 11.31.0 → 11.31.2
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.md +2 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +1 -1
- package/dist/core/modules/ai/core-ai.controller.js +4 -5
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +1 -1
- package/dist/core/modules/ai/core-ai.resolver.js +7 -6
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-conversation.model.d.ts +6 -0
- package/dist/core/modules/ai/models/core-ai-conversation.model.js +13 -1
- package/dist/core/modules/ai/models/core-ai-conversation.model.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +8 -2
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +53 -2
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +13 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +16 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +4 -0
- package/dist/core/modules/migrate/migration-runner.js +46 -6
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.31.0-to-11.31.1.md +89 -0
- package/migration-guides/11.31.1-to-11.31.2.md +127 -0
- package/package.json +1 -1
- package/src/core/modules/ai/core-ai.controller.ts +12 -9
- package/src/core/modules/ai/core-ai.resolver.ts +14 -9
- package/src/core/modules/ai/models/core-ai-conversation.model.ts +18 -1
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +110 -4
- package/src/core/modules/migrate/README.md +56 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +27 -3
- package/src/core/modules/migrate/migration-runner.ts +123 -6
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Migration Guide: 11.31.0 → 11.31.1
|
|
2
|
+
|
|
3
|
+
> Note: written on the feature branch (`feature/migrate-safe-ts-js-transition`).
|
|
4
|
+
> If the release lands under a different version number, rename this file accordingly.
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
| Category | Details |
|
|
9
|
+
|----------|---------|
|
|
10
|
+
| **Breaking Changes** | None |
|
|
11
|
+
| **New Features** | Extension-agnostic migration identity (safe ts-node → compiled-JS transition); strict integrity mode (`--strict` / `NSC__MIGRATE__STRICT` / `MigrationRunnerOptions.strict`); `status()` reports `missing` files; `migrate list` annotates missing files |
|
|
12
|
+
| **Bugfixes** | Co-present `foo.ts` + `foo.js` no longer execute twice in one `up()` run (deduplicated by identity, `.js` wins) |
|
|
13
|
+
| **Migration Effort** | None — no code changes required. Optional: enable strict mode in production images |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Migration
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Update package
|
|
21
|
+
npm install @lenne.tech/nest-server@11.31.1
|
|
22
|
+
|
|
23
|
+
# Verify build
|
|
24
|
+
npm run build
|
|
25
|
+
|
|
26
|
+
# Run tests
|
|
27
|
+
npm test
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No code changes required in any project.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What's New in 11.31.1
|
|
35
|
+
|
|
36
|
+
### Extension-agnostic migration identity (safe ts-node → compiled-JS transition)
|
|
37
|
+
|
|
38
|
+
`migrate up`, `migrate down`, and `migrate list` now compare migrations by their
|
|
39
|
+
timestamped file stem WITHOUT the `.ts`/`.js` extension. A migration recorded as
|
|
40
|
+
`1699-foo.ts` (run via ts-node) matches the compiled `1699-foo.js` in a production
|
|
41
|
+
image — already-applied migrations are no longer treated as "pending" and re-run
|
|
42
|
+
after switching the image to compiled JavaScript. No state rewrite is needed;
|
|
43
|
+
existing databases keep working as-is.
|
|
44
|
+
|
|
45
|
+
If both `foo.ts` and `foo.js` are present in the migrations directory (e.g. an
|
|
46
|
+
overlapping `outDir`), they now count as ONE migration: only the `.js` file is
|
|
47
|
+
loaded and executed, with a warning. Previously both files would have executed.
|
|
48
|
+
|
|
49
|
+
Note: the legacy `synchronizedUp()` helper drives the external `migrate` package's
|
|
50
|
+
own state handling and keeps raw-filename identity — the new semantics apply to the
|
|
51
|
+
`MigrationRunner`/built-in CLI path only.
|
|
52
|
+
|
|
53
|
+
### Missing migration files tolerated by default (up/list)
|
|
54
|
+
|
|
55
|
+
Old, already-applied migration files can be deleted (they live in git and can be
|
|
56
|
+
restored). `migrate up` no longer needs their files to be present:
|
|
57
|
+
|
|
58
|
+
- Default: `up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots. Previously such entries were silently ignored — log-scraping setups will see a NEW warning.
|
|
59
|
+
- `migrate list` marks affected entries with `(file missing)` and `status()` additionally returns them in a new `missing: string[]` field (additive, backward compatible).
|
|
60
|
+
- `migrate down` ALWAYS fails hard when the rollback file is missing — unchanged from 11.31.0, with a clearer message (`… restore the file from git to roll back.`). Rollback is an explicit operator action, never a boot path.
|
|
61
|
+
|
|
62
|
+
### Strict integrity mode (opt-in)
|
|
63
|
+
|
|
64
|
+
Turn the tolerated drift into a hard error (`up` throws, `list` exits non-zero):
|
|
65
|
+
|
|
66
|
+
| Activation path | Scope |
|
|
67
|
+
|-----------------|-------|
|
|
68
|
+
| `--strict` CLI flag | single invocation |
|
|
69
|
+
| `NSC__MIGRATE__STRICT=1\|true\|yes` env var (case-insensitive) | CLI **and** programmatic `MigrationRunner` instances |
|
|
70
|
+
| `new MigrationRunner({ strict: true, ... })` | programmatic |
|
|
71
|
+
|
|
72
|
+
**Recommended for production images:** set `NSC__MIGRATE__STRICT=true`. In an
|
|
73
|
+
immutable image, a recorded-but-missing migration file can only mean a broken build
|
|
74
|
+
or a state-store mismatch — refusing to boot is the correct reaction there.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Compatibility Notes
|
|
79
|
+
|
|
80
|
+
- `status()` return type gained a `missing: string[]` field. Code that destructures
|
|
81
|
+
only `completed`/`pending` continues to work unchanged.
|
|
82
|
+
- **Vendor mode:** `src/core/modules/migrate/migration-runner.ts` and
|
|
83
|
+
`src/core/modules/migrate/cli/migrate-cli.ts` are an atomic pair — sync BOTH
|
|
84
|
+
files together (the CLI passes `strict` into the runner's options interface and
|
|
85
|
+
imports `parseStrictEnv` from the runner).
|
|
86
|
+
|
|
87
|
+
## Module Documentation
|
|
88
|
+
|
|
89
|
+
- [Migrate module README](../src/core/modules/migrate/README.md) — see "Migration Identity & Strict Mode"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Migration Guide: 11.31.1 → 11.31.2
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None (one behavior change for admins — see below) |
|
|
8
|
+
| **New Features** | Admin cross-user conversation listing (`?all=true`), creator attribution (`createdByUser`) |
|
|
9
|
+
| **Bugfixes** | Non-admins can list their own AI conversations again (was HTTP 403 / GraphQL "Access denied") |
|
|
10
|
+
| **Migration Effort** | None for most projects — no code changes required |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Update package
|
|
18
|
+
pnpm update @lenne.tech/nest-server@11.31.2
|
|
19
|
+
|
|
20
|
+
# Verify
|
|
21
|
+
pnpm run build && pnpm test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No configuration changes are required. Everything below is automatic (the
|
|
25
|
+
bugfix) or opt-in (the two new features).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## What's New in 11.31.2
|
|
30
|
+
|
|
31
|
+
### 1. Non-admins can list their own AI conversations again (bugfix)
|
|
32
|
+
|
|
33
|
+
`GET /ai/conversations` and the GraphQL `findAiConversations` query returned
|
|
34
|
+
`403` / "Access denied" for every non-admin user, so their own conversations
|
|
35
|
+
never loaded. The list endpoints passed the per-document roles
|
|
36
|
+
`[ADMIN, S_CREATOR, S_SELF]`, which a LIST operation can never satisfy (there is
|
|
37
|
+
no single object to check ownership against), so non-admins were rejected before
|
|
38
|
+
ownership scoping ran.
|
|
39
|
+
|
|
40
|
+
Both endpoints now authorize the list with `S_USER` and scope the result to the
|
|
41
|
+
caller via a server-side `createdBy` filter plus the model's `securityCheck`.
|
|
42
|
+
Cross-user isolation is unchanged — a non-admin still only ever sees their own
|
|
43
|
+
conversations. **No action required**; it simply works after the update.
|
|
44
|
+
|
|
45
|
+
### 2. Admins can opt in to a cross-user view (`?all=true`)
|
|
46
|
+
|
|
47
|
+
By default every user — **admins included** — now receives only their own
|
|
48
|
+
conversations. An admin can list every user's conversations on demand:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# REST
|
|
52
|
+
GET /ai/conversations?all=true
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```graphql
|
|
56
|
+
# GraphQL
|
|
57
|
+
query { findAiConversations(all: true) { id title createdBy createdByUser } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The flag is ignored for non-admins, so it can never widen a regular user's
|
|
61
|
+
scope. Results are ordered newest-first.
|
|
62
|
+
|
|
63
|
+
### 3. Creator attribution in the admin cross-user view (`createdByUser`)
|
|
64
|
+
|
|
65
|
+
In the admin cross-user view (`all: true`) each conversation additionally
|
|
66
|
+
carries a resolved `createdByUser` object so the list is attributable to a named
|
|
67
|
+
user:
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"id": "…",
|
|
72
|
+
"title": "…",
|
|
73
|
+
"createdBy": "665f…", // owner user id (always present)
|
|
74
|
+
"createdByUser": { // only in the admin all-view
|
|
75
|
+
"id": "665f…",
|
|
76
|
+
"email": "user@example.com",
|
|
77
|
+
"firstName": "…",
|
|
78
|
+
"lastName": "…",
|
|
79
|
+
"username": "…"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
On a normal own-only fetch `createdByUser` is left unset (the owner is the
|
|
85
|
+
caller, so no lookup is needed).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Behavior Change (admins only)
|
|
90
|
+
|
|
91
|
+
Before 11.31.2, `GET /ai/conversations` returned **all** users' conversations
|
|
92
|
+
for an admin by default. It now returns the **admin's own** conversations by
|
|
93
|
+
default, matching every other role. If your admin UI relied on the unscoped
|
|
94
|
+
list, request the cross-user view explicitly with `?all=true` (REST) or
|
|
95
|
+
`all: true` (GraphQL). Non-admin behavior is unchanged.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Compatibility Notes
|
|
100
|
+
|
|
101
|
+
- `getConversation` / `deleteConversation` (single-object operations) are
|
|
102
|
+
unchanged — they still use `[ADMIN, S_CREATOR, S_SELF]`, which is correct
|
|
103
|
+
because a single loaded object exists to check ownership against.
|
|
104
|
+
- Projects that extend `CoreAiConversationService` and override its constructor
|
|
105
|
+
must forward the new `@InjectConnection()` parameter to `super(...)` (used to
|
|
106
|
+
resolve creators for the admin view). Projects that do not subclass the
|
|
107
|
+
service are unaffected.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Module Documentation
|
|
112
|
+
|
|
113
|
+
### AI Module
|
|
114
|
+
|
|
115
|
+
- **README:** [src/core/modules/ai/README.md](../src/core/modules/ai/README.md)
|
|
116
|
+
- **Integration Checklist:** [src/core/modules/ai/INTEGRATION-CHECKLIST.md](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
|
|
117
|
+
- **Reference Implementation:** `src/server/modules/ai/`
|
|
118
|
+
- **Key Files:**
|
|
119
|
+
- `core-ai.controller.ts` / `core-ai.resolver.ts` — list endpoints delegate to the shared service method
|
|
120
|
+
- `services/core-ai-conversation.service.ts` — `findForCurrentUser()` (shared owner-scoped list + admin all-scope + creator attribution)
|
|
121
|
+
- `models/core-ai-conversation.model.ts` — `createdByUser` field, ownership `securityCheck`
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## References
|
|
126
|
+
|
|
127
|
+
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.31.
|
|
3
|
+
"version": "11.31.2",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Body, Controller, Delete, Get, Param, Post, Put, Res } from '@nestjs/common';
|
|
1
|
+
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
|
|
2
2
|
import { Response } from 'express';
|
|
3
3
|
|
|
4
4
|
import { RESTServiceOptions } from '../../common/decorators/rest-service-options.decorator';
|
|
@@ -266,7 +266,9 @@ export class CoreAiController {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
/**
|
|
269
|
-
* Find the current user's AI conversations (
|
|
269
|
+
* Find the current user's AI conversations (own only by default). An admin may
|
|
270
|
+
* pass `?all=true` to list every user's conversations; each result carries its
|
|
271
|
+
* `createdBy` owner id for attribution. The flag is ignored for non-admins.
|
|
270
272
|
*
|
|
271
273
|
* The `messages` subdocument array is excluded from the list result — clients
|
|
272
274
|
* fetching the conversation detail via `getConversation` get the full message
|
|
@@ -274,13 +276,14 @@ export class CoreAiController {
|
|
|
274
276
|
*/
|
|
275
277
|
@Get('conversations')
|
|
276
278
|
@Roles(RoleEnum.S_USER)
|
|
277
|
-
async findConversations(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
async findConversations(
|
|
280
|
+
@RESTServiceOptions() serviceOptions: ServiceOptions,
|
|
281
|
+
@Query('all') all?: string,
|
|
282
|
+
): Promise<CoreAiConversation[]> {
|
|
283
|
+
// Owner-scoped list shared with the GraphQL resolver — see
|
|
284
|
+
// CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
|
|
285
|
+
// Admins default to their own conversations and opt in to the cross-user view via ?all=true.
|
|
286
|
+
return this.conversationService.findForCurrentUser(serviceOptions, { all: all === 'true' });
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
/**
|
|
@@ -270,21 +270,26 @@ export class CoreAiResolver {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/**
|
|
273
|
-
* Find the current user's AI conversations (
|
|
273
|
+
* Find the current user's AI conversations (own only by default). An admin may
|
|
274
|
+
* pass `all: true` to list every user's conversations; each result carries its
|
|
275
|
+
* `createdBy` owner id for attribution. The argument is ignored for non-admins.
|
|
274
276
|
*
|
|
275
277
|
* The `messages` subdocument array is excluded from the list result — clients
|
|
276
278
|
* fetching the conversation detail via `getAiConversation` get the full message
|
|
277
279
|
* history. List payloads stay small even for users with many long conversations.
|
|
278
280
|
*/
|
|
279
|
-
@Query(() => [CoreAiConversation], {
|
|
281
|
+
@Query(() => [CoreAiConversation], {
|
|
282
|
+
description: "Find AI conversations of the current user (admins may pass all: true to see every user's)",
|
|
283
|
+
})
|
|
280
284
|
@Roles(RoleEnum.S_USER)
|
|
281
|
-
async findAiConversations(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
async findAiConversations(
|
|
286
|
+
@GraphQLServiceOptions() serviceOptions: ServiceOptions,
|
|
287
|
+
@Args('all', { nullable: true, type: () => Boolean }) all?: boolean,
|
|
288
|
+
): Promise<CoreAiConversation[]> {
|
|
289
|
+
// Owner-scoped list shared with the REST controller — see
|
|
290
|
+
// CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
|
|
291
|
+
// Admins default to their own conversations and opt in to the cross-user view via all: true.
|
|
292
|
+
return this.conversationService.findForCurrentUser(serviceOptions, { all: all === true });
|
|
288
293
|
}
|
|
289
294
|
|
|
290
295
|
/**
|
|
@@ -6,6 +6,7 @@ import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
|
6
6
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
7
7
|
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
8
8
|
import { CorePersistenceModel } from '../../../common/models/core-persistence.model';
|
|
9
|
+
import { JSON } from '../../../common/scalars/json.scalar';
|
|
9
10
|
import { CoreAiMessage } from './core-ai-message.model';
|
|
10
11
|
|
|
11
12
|
export type AiConversationDocument = CoreAiConversation & Document;
|
|
@@ -45,6 +46,22 @@ export class CoreAiConversation extends CorePersistenceModel {
|
|
|
45
46
|
})
|
|
46
47
|
createdBy?: string = undefined;
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Resolved creator identity (`{ id, email, firstName, lastName, username }`),
|
|
51
|
+
* attached at read time ONLY for an admin's cross-user list view (`all: true`)
|
|
52
|
+
* so each conversation is attributable to a named user. Not persisted and left
|
|
53
|
+
* unset on a normal own-only fetch, where the owner is the caller.
|
|
54
|
+
*/
|
|
55
|
+
@UnifiedField({
|
|
56
|
+
description:
|
|
57
|
+
'Resolved creator identity (id, email, name) — set only in the admin cross-user list view, not persisted',
|
|
58
|
+
gqlType: JSON,
|
|
59
|
+
isOptional: true,
|
|
60
|
+
roles: RoleEnum.S_USER,
|
|
61
|
+
type: () => Object,
|
|
62
|
+
})
|
|
63
|
+
createdByUser?: Record<string, any> = undefined;
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Conversation messages (appended via $push).
|
|
50
67
|
*/
|
|
@@ -76,7 +93,7 @@ export class CoreAiConversation extends CorePersistenceModel {
|
|
|
76
93
|
if (force) {
|
|
77
94
|
return this;
|
|
78
95
|
}
|
|
79
|
-
if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === user.id))) {
|
|
96
|
+
if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === String(user.id)))) {
|
|
80
97
|
return this;
|
|
81
98
|
}
|
|
82
99
|
// Hide conversations owned by other users (filtered out of list responses).
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
-
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
-
import { Model, Types } from 'mongoose';
|
|
2
|
+
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Connection, Model, Types } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
6
|
+
import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
|
|
5
7
|
import { CrudService } from '../../../common/services/crud.service';
|
|
6
8
|
import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type';
|
|
7
9
|
import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-create.input';
|
|
@@ -21,8 +23,15 @@ export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constan
|
|
|
21
23
|
* CRUD service for multi-turn {@link CoreAiConversation}s.
|
|
22
24
|
*
|
|
23
25
|
* `appendMessage()` adds a turn via `$push` (never round-trips the subdocument
|
|
24
|
-
* array through `update()`).
|
|
25
|
-
*
|
|
26
|
+
* array through `update()`). `findForCurrentUser()` is the shared owner-scoped
|
|
27
|
+
* list used by both the REST controller and the GraphQL resolver.
|
|
28
|
+
*
|
|
29
|
+
* Ownership is enforced differently per operation shape:
|
|
30
|
+
* - single-object `get`/`delete`: the controller/resolver pass
|
|
31
|
+
* `S_CREATOR`/`S_SELF`, evaluated against the loaded `dbObject`.
|
|
32
|
+
* - list (`findForCurrentUser`): a list has no single `dbObject`, so the gate
|
|
33
|
+
* uses `S_USER` and ownership is scoped by the server-computed `createdBy`
|
|
34
|
+
* filterQuery plus the model's `securityCheck`.
|
|
26
35
|
*/
|
|
27
36
|
@Injectable()
|
|
28
37
|
export class CoreAiConversationService extends CrudService<
|
|
@@ -37,10 +46,107 @@ export class CoreAiConversationService extends CrudService<
|
|
|
37
46
|
@InjectModel(AI_CONVERSATION_MODEL) protected override readonly mainDbModel: Model<AiConversationDocument>,
|
|
38
47
|
@Inject(AI_CONVERSATION_CLASS)
|
|
39
48
|
protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiConversation>,
|
|
49
|
+
@InjectConnection() protected readonly connection: Connection,
|
|
40
50
|
) {
|
|
41
51
|
super();
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/**
|
|
55
|
+
* List the AI conversations visible to the current user. By default — for
|
|
56
|
+
* every user, admins included — this returns only the caller's own
|
|
57
|
+
* conversations, newest first. An admin may opt in to the cross-user view
|
|
58
|
+
* (every user's conversations) by passing `{ all: true }`; the flag is
|
|
59
|
+
* ignored for non-admins, so it can never widen a regular user's scope.
|
|
60
|
+
*
|
|
61
|
+
* Each result carries its `createdBy` owner id. In the admin cross-user view
|
|
62
|
+
* (`all: true`) each result additionally carries a resolved `createdByUser`
|
|
63
|
+
* (`{ id, email, firstName, lastName, username }`) so the list is attributable
|
|
64
|
+
* to named users; on a normal own-only fetch that lookup is skipped, since the
|
|
65
|
+
* owner is the caller. The heavy `messages` subdocument array is excluded from
|
|
66
|
+
* the list payload — fetch a single conversation via `get()` for the full
|
|
67
|
+
* message history.
|
|
68
|
+
*
|
|
69
|
+
* Shared by the REST controller (`GET /ai/conversations`) and the GraphQL
|
|
70
|
+
* resolver (`findAiConversations`) so both API surfaces enforce ownership
|
|
71
|
+
* identically and cannot drift apart.
|
|
72
|
+
*
|
|
73
|
+
* A LIST operation has no single `dbObject`, so the per-document roles
|
|
74
|
+
* `S_CREATOR` / `S_SELF` can never be satisfied at the operation level —
|
|
75
|
+
* passing them rejected every non-admin with a 403 before ownership scoping
|
|
76
|
+
* ran. The operation gate therefore uses `[S_USER]`; the server-computed
|
|
77
|
+
* `createdBy` filterQuery and the model `securityCheck` scope the result to
|
|
78
|
+
* the owner.
|
|
79
|
+
*/
|
|
80
|
+
async findForCurrentUser(
|
|
81
|
+
serviceOptions?: ServiceOptions,
|
|
82
|
+
options?: { all?: boolean },
|
|
83
|
+
): Promise<CoreAiConversation[]> {
|
|
84
|
+
const currentUser = serviceOptions?.currentUser;
|
|
85
|
+
const isAdmin = !!currentUser?.roles?.includes(RoleEnum.ADMIN);
|
|
86
|
+
// Default is own-only for everyone; only an admin may opt in to all users' conversations.
|
|
87
|
+
const seeAll = isAdmin && options?.all === true;
|
|
88
|
+
const filterQuery = seeAll ? {} : { createdBy: currentUser?.id };
|
|
89
|
+
const conversations = await this.find(
|
|
90
|
+
{ filterQuery, queryOptions: { sort: { createdAt: -1 } } },
|
|
91
|
+
{ ...serviceOptions, roles: [RoleEnum.S_USER], select: '-messages' },
|
|
92
|
+
);
|
|
93
|
+
// Resolve creators to named users ONLY for the admin cross-user view — a normal
|
|
94
|
+
// fetch returns just the caller's own conversations, so the owner is already known.
|
|
95
|
+
if (seeAll && conversations.length) {
|
|
96
|
+
await this.attachCreators(conversations);
|
|
97
|
+
}
|
|
98
|
+
return conversations;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attach a lightweight resolved creator (`{ id, email, firstName, lastName,
|
|
103
|
+
* username }`) as `createdByUser` to each conversation, so an admin's
|
|
104
|
+
* cross-user list is attributable to named users. Read-only lookup of
|
|
105
|
+
* non-sensitive identity fields via the shared `User` model on the same
|
|
106
|
+
* connection; fails soft (leaves `createdByUser` unset) when no `User` model is
|
|
107
|
+
* registered. Never called on a normal own-only fetch.
|
|
108
|
+
*/
|
|
109
|
+
protected async attachCreators(conversations: CoreAiConversation[]): Promise<void> {
|
|
110
|
+
const ids = [
|
|
111
|
+
...new Set(
|
|
112
|
+
conversations
|
|
113
|
+
.map((conversation) => conversation.createdBy)
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map(String),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
if (!ids.length) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let userModel: Model<any>;
|
|
122
|
+
try {
|
|
123
|
+
userModel = this.connection.model('User');
|
|
124
|
+
} catch {
|
|
125
|
+
// No `User` model registered on this connection (exotic setup) — skip attribution.
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const users = await userModel
|
|
129
|
+
.find({ _id: { $in: ids } })
|
|
130
|
+
.select('email firstName lastName username')
|
|
131
|
+
.lean()
|
|
132
|
+
.exec();
|
|
133
|
+
const byId = new Map(
|
|
134
|
+
(users as any[]).map((user) => [
|
|
135
|
+
String(user._id),
|
|
136
|
+
{
|
|
137
|
+
email: user.email,
|
|
138
|
+
firstName: user.firstName,
|
|
139
|
+
id: String(user._id),
|
|
140
|
+
lastName: user.lastName,
|
|
141
|
+
username: user.username,
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
for (const conversation of conversations) {
|
|
146
|
+
conversation.createdByUser = byId.get(String(conversation.createdBy));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
44
150
|
/**
|
|
45
151
|
* Append a message to a conversation via `$push` (system-internal; the user
|
|
46
152
|
* already authorized the prompt that produced it). The array is capped at
|
|
@@ -432,6 +432,62 @@ The `migrate` command comes from `@lenne.tech/nest-server` - no external package
|
|
|
432
432
|
|
|
433
433
|
See the [Migration Guide](./MIGRATION_FROM_NODEPIT.md) for detailed migration instructions from @nodepit.
|
|
434
434
|
|
|
435
|
+
## Migration Identity & Strict Mode
|
|
436
|
+
|
|
437
|
+
### Extension-agnostic identity (safe ts-node → compiled-JS transition)
|
|
438
|
+
|
|
439
|
+
A migration's identity is its timestamped file stem **without** the `.ts`/`.js` extension
|
|
440
|
+
(`migrationId('1699-foo.ts') === migrationId('1699-foo.js') === '1699-foo'`). `up`, `down`,
|
|
441
|
+
and `list`/`status` all compare identities, not raw filenames.
|
|
442
|
+
|
|
443
|
+
This makes the recommended production setup — running compiled `.js` migrations in the image
|
|
444
|
+
while the state was recorded under `.ts` names via ts-node — safe: already-applied migrations
|
|
445
|
+
are **not** re-run after the switch, and no state rewrite is needed. If both `foo.ts` and
|
|
446
|
+
`foo.js` are present in the migrations directory (e.g. an overlapping `outDir`), they count
|
|
447
|
+
as ONE migration: only the `.js` file is loaded and executed, and a warning is emitted.
|
|
448
|
+
|
|
449
|
+
Note: identity normalization applies to the `MigrationRunner`/CLI path only. The legacy
|
|
450
|
+
`synchronizedUp()` helper drives the external `migrate` package's own state handling and
|
|
451
|
+
keeps raw-filename identity.
|
|
452
|
+
|
|
453
|
+
### Missing migration files & strict mode
|
|
454
|
+
|
|
455
|
+
Old, already-applied migration files can be deleted (they live in git and can be restored).
|
|
456
|
+
By default the runner **tolerates** a recorded migration whose file is gone:
|
|
457
|
+
|
|
458
|
+
- `migrate up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots.
|
|
459
|
+
- `migrate list` marks the entry with `(file missing)`.
|
|
460
|
+
- `migrate down` **always fails hard** on a missing rollback file — independent of strict mode. Rollback is an explicit operator action, never a boot path; exiting 0 without rolling anything back would mislead scripts. Restore the file from git, then retry.
|
|
461
|
+
|
|
462
|
+
Strict mode turns the tolerated drift into a hard error (`up` throws, `list` exits non-zero).
|
|
463
|
+
Enable it via any of:
|
|
464
|
+
|
|
465
|
+
| Activation path | Scope |
|
|
466
|
+
| -------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
467
|
+
| `--strict` CLI flag | single invocation |
|
|
468
|
+
| `NSC__MIGRATE__STRICT=1\|true\|yes` env var | CLI **and** programmatic runners (resolved in the `MigrationRunner` constructor) |
|
|
469
|
+
| `new MigrationRunner({ strict: true, ... })` | programmatic |
|
|
470
|
+
|
|
471
|
+
**Recommended for production images:** set `NSC__MIGRATE__STRICT=true` in the container
|
|
472
|
+
environment. In an immutable image, a recorded-but-missing migration file can only mean a
|
|
473
|
+
broken build (empty/miscopied `migrations/` directory) or a state-store mismatch (wrong
|
|
474
|
+
database) — both are conditions where refusing to boot is correct.
|
|
475
|
+
|
|
476
|
+
### Programmatic usage (MigrationRunner)
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
import { MigrationRunner, MongoStateStore } from '@lenne.tech/nest-server';
|
|
480
|
+
|
|
481
|
+
const runner = new MigrationRunner({
|
|
482
|
+
migrationsDirectory: './migrations',
|
|
483
|
+
stateStore: new MongoStateStore(process.env.NSC__MONGOOSE__URI),
|
|
484
|
+
strict: true, // optional; defaults to NSC__MIGRATE__STRICT, else false
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await runner.up();
|
|
488
|
+
const { completed, missing, pending } = await runner.status();
|
|
489
|
+
```
|
|
490
|
+
|
|
435
491
|
## Project Integration
|
|
436
492
|
|
|
437
493
|
The migration utilities are designed to minimize boilerplate in your projects. Instead of copying multiple utility files, you can:
|
|
@@ -17,18 +17,21 @@
|
|
|
17
17
|
* --store, -s Path to state store module
|
|
18
18
|
* --compiler, -c Compiler to use (e.g., ts:./path/to/ts-compiler.js)
|
|
19
19
|
* --template-file, -t Template file for creating migrations
|
|
20
|
+
* --strict Fail if a recorded migration's file is missing (default: off / tolerate).
|
|
21
|
+
* Applies to up/list; down always fails hard on a missing rollback file.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import * as fs from 'fs';
|
|
23
25
|
import * as path from 'path';
|
|
24
26
|
|
|
25
|
-
import { MigrationRunner } from '../migration-runner';
|
|
27
|
+
import { MigrationRunner, parseStrictEnv } from '../migration-runner';
|
|
26
28
|
import { MongoStateStore } from '../mongo-state-store';
|
|
27
29
|
|
|
28
30
|
interface CliOptions {
|
|
29
31
|
compiler?: string;
|
|
30
32
|
migrationsDir: string;
|
|
31
33
|
store?: string;
|
|
34
|
+
strict?: boolean;
|
|
32
35
|
templateFile?: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -90,9 +93,11 @@ async function listMigrations(options: CliOptions) {
|
|
|
90
93
|
const runner = new MigrationRunner({
|
|
91
94
|
migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
|
|
92
95
|
stateStore,
|
|
96
|
+
strict: options.strict,
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
const status = await runner.status();
|
|
100
|
+
const missing = new Set(status.missing);
|
|
96
101
|
|
|
97
102
|
console.log('\nMigration Status:');
|
|
98
103
|
console.log('=================\n');
|
|
@@ -100,7 +105,7 @@ async function listMigrations(options: CliOptions) {
|
|
|
100
105
|
if (status.completed.length > 0) {
|
|
101
106
|
console.log('Completed:');
|
|
102
107
|
status.completed.forEach((name) => {
|
|
103
|
-
console.log(` ✓ ${name}`);
|
|
108
|
+
console.log(` ✓ ${name}${missing.has(name) ? ' (file missing)' : ''}`);
|
|
104
109
|
});
|
|
105
110
|
console.log('');
|
|
106
111
|
}
|
|
@@ -116,6 +121,14 @@ async function listMigrations(options: CliOptions) {
|
|
|
116
121
|
if (status.completed.length === 0 && status.pending.length === 0) {
|
|
117
122
|
console.log('No migrations found\n');
|
|
118
123
|
}
|
|
124
|
+
|
|
125
|
+
// In strict mode, integrity drift is an error also on the inspection surface —
|
|
126
|
+
// exit non-zero (via main's error handler) instead of hiding it in the listing.
|
|
127
|
+
if (options.strict && status.missing.length > 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Strict mode: ${status.missing.length} recorded migration file(s) missing: ${status.missing.join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
/**
|
|
@@ -198,6 +211,9 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
|
|
|
198
211
|
let name: string | undefined;
|
|
199
212
|
const options: CliOptions = {
|
|
200
213
|
migrationsDir: './migrations',
|
|
214
|
+
// Off by default: a recorded migration whose file was deleted is tolerated so the
|
|
215
|
+
// server still starts. Enable per-run via `--strict` or globally via NSC__MIGRATE__STRICT.
|
|
216
|
+
strict: parseStrictEnv(),
|
|
201
217
|
};
|
|
202
218
|
|
|
203
219
|
// Check if second arg is a name (not a flag)
|
|
@@ -217,6 +233,8 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
|
|
|
217
233
|
options.compiler = args[++i];
|
|
218
234
|
} else if (arg === '--template-file' || arg === '-t') {
|
|
219
235
|
options.templateFile = args[++i];
|
|
236
|
+
} else if (arg === '--strict') {
|
|
237
|
+
options.strict = true;
|
|
220
238
|
}
|
|
221
239
|
}
|
|
222
240
|
|
|
@@ -254,6 +272,7 @@ async function runDown(options: CliOptions) {
|
|
|
254
272
|
const runner = new MigrationRunner({
|
|
255
273
|
migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
|
|
256
274
|
stateStore,
|
|
275
|
+
strict: options.strict,
|
|
257
276
|
});
|
|
258
277
|
|
|
259
278
|
await runner.down();
|
|
@@ -269,6 +288,7 @@ async function runUp(options: CliOptions) {
|
|
|
269
288
|
const runner = new MigrationRunner({
|
|
270
289
|
migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
|
|
271
290
|
stateStore,
|
|
291
|
+
strict: options.strict,
|
|
272
292
|
});
|
|
273
293
|
|
|
274
294
|
await runner.up();
|
|
@@ -292,6 +312,8 @@ Options:
|
|
|
292
312
|
--store, -s <path> Path to state store module
|
|
293
313
|
--compiler, -c <compiler> Compiler to use (e.g., ts:./path/to/ts-compiler.js)
|
|
294
314
|
--template-file, -t <path> Template file for creating migrations
|
|
315
|
+
--strict Fail if a recorded migration's file is missing (default: off / tolerate).
|
|
316
|
+
Applies to up/list; down always fails hard on a missing rollback file.
|
|
295
317
|
|
|
296
318
|
Examples:
|
|
297
319
|
migrate create add-user-email
|
|
@@ -302,6 +324,7 @@ Examples:
|
|
|
302
324
|
|
|
303
325
|
Environment Variables:
|
|
304
326
|
NODE_ENV Set environment (e.g., development, production)
|
|
327
|
+
NSC__MIGRATE__STRICT 1|true|yes → fail on a missing recorded migration file (default: tolerate)
|
|
305
328
|
`);
|
|
306
329
|
}
|
|
307
330
|
|
|
@@ -313,4 +336,5 @@ if (require.main === module) {
|
|
|
313
336
|
});
|
|
314
337
|
}
|
|
315
338
|
|
|
316
|
-
|
|
339
|
+
// parseArgs is exported for unit testing only (same pattern as resolveCliPath in bin/migrate.js)
|
|
340
|
+
export { main, parseArgs };
|