@miguelmorales13/nestkit 0.6.0 → 0.7.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.
- package/README.md +170 -5
- package/dist/bootstrap/index.cjs +3 -3
- package/dist/bootstrap/index.js +2 -2
- package/dist/database/typeorm/index.cjs +79 -0
- package/dist/database/typeorm/index.d.ts +3 -0
- package/dist/database/typeorm/index.js +79 -0
- package/dist/database/typeorm/tenant-scope.d.ts +18 -0
- package/dist/database/typeorm/typeorm.module.d.ts +35 -0
- package/dist/index.cjs +11 -11
- package/dist/index.js +18 -18
- package/package.json +12 -2
- package/dist/{chunk-AOCF5QCZ.js → chunk-ANQ3YPDI.js} +3 -3
- package/dist/{chunk-54ZXIB5T.cjs → chunk-YARLPYG5.cjs} +2 -2
package/README.md
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
# @miguelmorales13/nestkit
|
|
2
2
|
|
|
3
|
-
Bloques de infraestructura reutilizables para servicios NestJS
|
|
4
|
-
respuestas estandarizadas, tracking de requests, entidades base, CRUD genérico, y conexión a
|
|
5
|
-
Postgres/Supabase (con el contrato listo para Mongo a futuro). Pensado para eliminar el boilerplate
|
|
3
|
+
Bloques de infraestructura reutilizables para servicios NestJS. Pensado para eliminar el boilerplate
|
|
6
4
|
que se repite al arrancar cada backend nuevo.
|
|
7
5
|
|
|
6
|
+
**El esqueleto**: entidades base, `Repository<T>` y CRUD genérico, respuestas estandarizadas, manejo
|
|
7
|
+
de errores, tracking de requests, i18n, y un `applyNestKitDefaults` que lo cablea todo en una línea.
|
|
8
|
+
|
|
9
|
+
**Las conexiones**: Postgres (con aislamiento por tenant vía RLS) y Supabase — el contrato de Mongo
|
|
10
|
+
está listo, la implementación no.
|
|
11
|
+
|
|
12
|
+
**Las integraciones**, cada una en su subpath y sin arrastrar dependencias a quien no la use: acceso
|
|
13
|
+
con Google o Facebook (`auth/oauth`) o con email y contraseña (`auth`), Stripe, Telegram, WhatsApp,
|
|
14
|
+
Instagram y Facebook Pages (`meta`), subida de archivos (`storage`), email (Resend o Nodemailer) y
|
|
15
|
+
lectura de analítica (`umami`).
|
|
16
|
+
|
|
8
17
|
## ¿Para qué proyectos es esto?
|
|
9
18
|
|
|
10
19
|
**Sí**: cualquier proyecto NestJS nuevo que arranque con el patrón hexagonal/ports-and-adapters
|
|
@@ -31,16 +40,30 @@ tu proyecto realmente use:
|
|
|
31
40
|
|---------------------|------------------|
|
|
32
41
|
| `@nestjs/common`, `@nestjs/core`, `reflect-metadata`, `rxjs` | `pg` → `./database/postgres` |
|
|
33
42
|
| | `@supabase/supabase-js` → `./database/supabase` |
|
|
34
|
-
| | `helmet`, `@nestjs/swagger` → `./bootstrap`
|
|
43
|
+
| | `helmet`, `@nestjs/swagger` → `./bootstrap` |
|
|
44
|
+
| | `telegraf` → `./telegram` |
|
|
45
|
+
| | `stripe` → `./stripe` |
|
|
46
|
+
| | `resend` → `./email/resend` |
|
|
47
|
+
| | `nodemailer` → `./email/nodemailer` |
|
|
48
|
+
| | `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner` → `./storage` (solo el adaptador S3) |
|
|
35
49
|
|
|
36
50
|
```bash
|
|
37
51
|
bun add @nestjs/common @nestjs/core reflect-metadata rxjs
|
|
38
52
|
bun add pg # si usas ./database/postgres
|
|
39
53
|
bun add @supabase/supabase-js # si usas ./database/supabase
|
|
40
54
|
bun add helmet @nestjs/swagger # si usas ./bootstrap
|
|
55
|
+
bun add telegraf # si usas ./telegram
|
|
56
|
+
bun add stripe # si usas ./stripe
|
|
41
57
|
bun add class-validator class-transformer # requeridos en runtime por ValidationPipe
|
|
42
58
|
```
|
|
43
59
|
|
|
60
|
+
`meta`, `media`, `umami` y `auth/oauth` no piden nada extra: hablan con sus APIs por el `fetch` del
|
|
61
|
+
runtime. `auth/oauth` sí necesita cookies en el adaptador HTTP de tu app —`@fastify/cookie` o
|
|
62
|
+
`cookie-parser`—, que es tuyo, no del paquete.
|
|
63
|
+
|
|
64
|
+
`bcryptjs` y `jsonwebtoken` (los usan `auth` y `auth/oauth`) van como dependencias normales, ocultas:
|
|
65
|
+
no los instalas ni los importas vos.
|
|
66
|
+
|
|
44
67
|
## Quickstart (`main.ts`)
|
|
45
68
|
|
|
46
69
|
```ts
|
|
@@ -209,6 +232,31 @@ hace que Row-Level-Security de Postgres vea el usuario correcto: abre una transa
|
|
|
209
232
|
commit/rollback — **cualquier query que dependa de una policy RLS basada en `auth.uid()` debe pasar
|
|
210
233
|
por aquí**, `pool.query()` directo no aplica el `set_config` y verá (o no verá) filas incorrectamente.
|
|
211
234
|
|
|
235
|
+
### `database/typeorm` — DataSource + RLS por tenant, para el que quiere ORM
|
|
236
|
+
|
|
237
|
+
Mismo modelo que `database/postgres`, pero con **TypeORM** para quien prefiere entidades,
|
|
238
|
+
repositorios y migraciones del ORM. `TypeOrmDbModule.forRoot({ entities, migrations })` provee un
|
|
239
|
+
`DataSource` inicializado bajo el token `DATA_SOURCE` (`type: 'postgres'` fijo, `synchronize`
|
|
240
|
+
siempre apagado — los cambios de esquema van por migraciones, jamás por auto-sync). Funciona con
|
|
241
|
+
cualquier Postgres, incluido **Neon**.
|
|
242
|
+
|
|
243
|
+
Lo importante para multi-tenant: el pool del ORM no tiene sesión por-request, así que la RLS no
|
|
244
|
+
vería el tenant. Por eso este subpath trae su propio **`withTenantScope(dataSource, session, fn)`**,
|
|
245
|
+
que abre un QueryRunner, corre `set_config(name, value, true)` por cada variable de sesión, y te pasa
|
|
246
|
+
el `EntityManager` **atado a esa conexión y transacción** — dentro, `manager.getRepository(X).find()`
|
|
247
|
+
corre con la RLS aplicada; fuera, no. Es el equivalente en TypeORM del `withTenantScope` de `pg`, y
|
|
248
|
+
está probado: con una policy sobre un GUC, cada tenant ve solo lo suyo y sin scope no ve nada.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
await withTenantScope(dataSource, {
|
|
252
|
+
'request.jwt.claim.sub': userId,
|
|
253
|
+
'request.jwt.claim.tenant': tenantId,
|
|
254
|
+
}, (manager) => manager.getRepository(Order).find());
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Peer dep opcional: `typeorm` (solo si se usa este módulo). Elegí ESTE o `database/postgres`, no los
|
|
258
|
+
dos a la vez — son dos formas de lo mismo.
|
|
259
|
+
|
|
212
260
|
### `database/supabase` — clientes anon y service-role
|
|
213
261
|
|
|
214
262
|
```ts
|
|
@@ -449,6 +497,44 @@ razonable para la Cloud API, que de todos modos es un REST plano, así que `What
|
|
|
449
497
|
`fetch` global de Node (18+) directo. Solo manda mensajes de texto (`sendTextMessage`) — sin
|
|
450
498
|
templates, sin media, sin manejo de mensajes entrantes.
|
|
451
499
|
|
|
500
|
+
### `meta` — publicar en Instagram y Facebook Pages
|
|
501
|
+
|
|
502
|
+
Cliente de salida sobre la Graph API, solo para publicar: no lee insights, ni maneja webhooks, ni
|
|
503
|
+
comentarios. Mismo alcance que `telegram`.
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { MetaModule } from '@miguelmorales13/nestkit/meta';
|
|
507
|
+
|
|
508
|
+
@Module({ imports: [MetaModule.forRoot()] }) // lee META_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID
|
|
509
|
+
export class AppModule {}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
await meta.publishToInstagram(url, 'Texto del pie', 'feed'); // 'feed' | 'stories' | 'reels'
|
|
514
|
+
await meta.publishToPage(url, 'Texto');
|
|
515
|
+
await meta.publishStoryToPage(url);
|
|
516
|
+
await meta.describe(); // a qué cuenta apunta el token, para diagnosticar
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Dos cosas de esta API mandan sobre todo lo demás, y conviene saberlas antes:
|
|
520
|
+
|
|
521
|
+
**Las imágenes nunca se suben.** Se pasa una URL pública y Meta la descarga. Solo la necesita
|
|
522
|
+
durante la publicación —después guarda su propia copia y sirve el archivo desde su CDN—, así que
|
|
523
|
+
alcanza con un hosting efímero. Para eso está `media`, en este mismo paquete.
|
|
524
|
+
|
|
525
|
+
**Publicar en una Página exige un Page token, no un user token.** Con un token de usuario Meta
|
|
526
|
+
asume que estás posteando como persona y pide `publish_actions`, un permiso que eliminó en 2018, así
|
|
527
|
+
que el error que devuelve no apunta ni de cerca a la causa real.
|
|
528
|
+
|
|
529
|
+
Permisos: `instagram_basic` e `instagram_content_publish` para Instagram, `pages_manage_posts` para
|
|
530
|
+
Facebook. Publicar en tus propias Páginas no necesita App Review; hacerlo en nombre de terceros sí.
|
|
531
|
+
|
|
532
|
+
Instagram publica en dos pasos (crear contenedor, publicar) y el servicio espera solo a que el
|
|
533
|
+
contenedor esté listo — no hace falta encadenarlo a mano.
|
|
534
|
+
|
|
535
|
+
`forRoot()` sin argumentos toma las credenciales del entorno; pasándoselas explícitas se puede tener
|
|
536
|
+
un proceso publicando en varias cuentas.
|
|
537
|
+
|
|
452
538
|
### `stripe` — cliente de la SDK oficial
|
|
453
539
|
|
|
454
540
|
```ts
|
|
@@ -799,6 +885,82 @@ Bunny y local no requieren nada extra. Variables de entorno reconocidas: `STORAG
|
|
|
799
885
|
Storage (no hay firma que oculte la contraseña de la zona); en esos casos se sube por la API, o se
|
|
800
886
|
usa Bunny Stream/TUS para video grande.
|
|
801
887
|
|
|
888
|
+
### `media` — hosting efímero para APIs que descargan por URL
|
|
889
|
+
|
|
890
|
+
No es almacenamiento. Es el complemento de `meta`: existe porque hay APIs —la de Meta es el caso
|
|
891
|
+
típico— que **rechazan el binario** y exigen bajarse el archivo ellas mismas, pero solo lo necesitan
|
|
892
|
+
durante la llamada. Sin algo así, el apaño habitual es commitear las imágenes al repositorio, que
|
|
893
|
+
las conserva para siempre por una URL que importó un minuto.
|
|
894
|
+
|
|
895
|
+
```ts
|
|
896
|
+
import { MediaModule } from '@miguelmorales13/nestkit/media';
|
|
897
|
+
|
|
898
|
+
MediaModule.forRoot({
|
|
899
|
+
publicUrl: 'https://api.tuapp.com/api', // ¡con el prefijo global!
|
|
900
|
+
uploadSecret: process.env.MEDIA_SECRET,
|
|
901
|
+
ttlMs: 60 * 60 * 1000, // por defecto una hora
|
|
902
|
+
})
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
Expone `POST /media` (devuelve `{ url }`) y `GET /media/:id`. La subida va protegida con un secreto
|
|
906
|
+
compartido en la cabecera `x-media-secret`, no con un JWT, porque quien llama es un job de CI y no
|
|
907
|
+
una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. La
|
|
908
|
+
descarga es pública a propósito: los servicios que consumen estas URLs las piden sin credenciales.
|
|
909
|
+
|
|
910
|
+
**Vive en memoria, a propósito.** Entre guardar y publicar pasan segundos, así que un reinicio en el
|
|
911
|
+
medio solo significa reintentar, y no queda nada en disco. Si necesitás que los archivos sobrevivan
|
|
912
|
+
a un reinicio, esta es la herramienta equivocada: usá `storage`.
|
|
913
|
+
|
|
914
|
+
Dos errores que cuestan una tarde:
|
|
915
|
+
|
|
916
|
+
- `publicUrl` **tiene que incluir el prefijo global** de la app. Sin él la URL cae en lo que sirva el
|
|
917
|
+
frontend, que responde 200 con una página HTML, y el consumidor se descarga eso en vez del archivo.
|
|
918
|
+
- Fastify solo parsea JSON de fábrica. Sin registrar un parser para los tipos que vas a aceptar, la
|
|
919
|
+
subida responde 415:
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
app.getHttpAdapter().getInstance().addContentTypeParser(
|
|
923
|
+
/^image\//, { parseAs: 'buffer' }, (_req, body, done) => done(null, body),
|
|
924
|
+
)
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
### `umami` — leer la API de analítica
|
|
928
|
+
|
|
929
|
+
Solo lee. Los eventos los manda el script del navegador; un backend escribiéndolos estaría
|
|
930
|
+
reportando tráfico que nunca ocurrió.
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
import { UmamiModule, UmamiService } from '@miguelmorales13/nestkit/umami';
|
|
934
|
+
|
|
935
|
+
UmamiModule.forRoot({
|
|
936
|
+
apiKey: process.env.UMAMI_API_KEY, // Umami Cloud: Settings → API Keys
|
|
937
|
+
websiteId: process.env.UMAMI_WEBSITE_ID,
|
|
938
|
+
timezone: 'America/Mexico_City',
|
|
939
|
+
// baseUrl: 'https://tu-host/api', // solo si es self-hosted
|
|
940
|
+
})
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
```ts
|
|
944
|
+
const rango = UmamiService.lastDays(7);
|
|
945
|
+
await umami.stats(rango); // visitantes, pageviews, visitas, rebotes
|
|
946
|
+
await umami.metrics('referrer', rango, 5); // top 5 de dónde llegan
|
|
947
|
+
await umami.eventCount('registro', rango); // cuántas veces disparó un evento
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
**Todos los métodos devuelven `null` en vez de lanzar** cuando la llamada falla. Un resumen suele
|
|
951
|
+
pedir varias cosas a la vez, y que un endpoint esté caído no debería llevarse el reporte entero; quien
|
|
952
|
+
llama decide qué significa un bloque ausente. Los fallos se loguean, así que uno persistente sigue
|
|
953
|
+
siendo visible.
|
|
954
|
+
|
|
955
|
+
`eventCount` distingue `0` de `null` a propósito: uno es «nadie lo hizo», el otro es «no sabemos».
|
|
956
|
+
|
|
957
|
+
`timezone` no es opcional en la práctica. Umami agrupa por día en esa zona, y dejarlo sin poner
|
|
958
|
+
reporta días UTC en silencio — que fuera de UTC significa un «día» que empieza a media tarde.
|
|
959
|
+
|
|
960
|
+
Deliberadamente no opina sobre qué significan los números: qué eventos forman un embudo, cómo
|
|
961
|
+
llamarlos y cómo redactar un reporte son decisiones de producto que cambian por proyecto. Esto solo
|
|
962
|
+
trae los datos.
|
|
963
|
+
|
|
802
964
|
## Subpaths disponibles
|
|
803
965
|
|
|
804
966
|
| Subpath | Qué trae |
|
|
@@ -810,10 +972,13 @@ usa Bunny Stream/TUS para video grande.
|
|
|
810
972
|
| `@miguelmorales13/nestkit/tracking` | `RequestContext`, `RequestIdMiddleware`, `TrackingModule` |
|
|
811
973
|
| `@miguelmorales13/nestkit/crud` | `Repository<T>`, `BaseCrudService`, `createCrudController` |
|
|
812
974
|
| `@miguelmorales13/nestkit/database/postgres` | `PgModule`, `PG_POOL`, `withTenantScope` |
|
|
975
|
+
| `@miguelmorales13/nestkit/database/typeorm` | `TypeOrmDbModule`, `DATA_SOURCE`, `withTenantScope` |
|
|
813
976
|
| `@miguelmorales13/nestkit/database/supabase` | `SupabaseModule`, `SUPABASE_ANON_CLIENT`, `SUPABASE_SERVICE_ROLE_CLIENT` |
|
|
814
977
|
| `@miguelmorales13/nestkit/database/mongo` | Solo el contrato `MongoRepositoryPort` — sin implementación |
|
|
815
978
|
| `@miguelmorales13/nestkit/telegram` | `TelegramModule`, `TELEGRAM_BOT` |
|
|
816
979
|
| `@miguelmorales13/nestkit/whatsapp` | `WhatsAppModule`, `WHATSAPP_CLIENT`, `WhatsAppClient` |
|
|
980
|
+
| `@miguelmorales13/nestkit/meta` | `MetaModule`, `MetaService` — publicar en Instagram y Facebook Pages |
|
|
981
|
+
| `@miguelmorales13/nestkit/media` | `MediaModule`, `MediaService` — hosting efímero en memoria |
|
|
817
982
|
| `@miguelmorales13/nestkit/stripe` | `StripeModule`, `STRIPE_CLIENT`, `createStripeWebhookController` |
|
|
818
983
|
| `@miguelmorales13/nestkit/auth` | `AuthUserPort`, `BaseAuthService`, `createAuthController`, `TokenService`, `JwtAuthGuard`, `RolesGuard`, `RequireTenantGuard`, `CurrentUser`, `CurrentTenant`, `Roles` |
|
|
819
984
|
| `@miguelmorales13/nestkit/auth/oauth` | `OAuthAuthModule`, `OAuthAuthService`, `createOAuthController`, `googleProvider`, `facebookProvider`, `OAuthStorePort`, `RefreshTokenStorePort` |
|
|
@@ -826,7 +991,7 @@ usa Bunny Stream/TUS para video grande.
|
|
|
826
991
|
|
|
827
992
|
## Estado del paquete
|
|
828
993
|
|
|
829
|
-
`0.
|
|
994
|
+
`0.7.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
|
|
830
995
|
|
|
831
996
|
`auth/oauth` y `umami` sí tienen un consumidor real: **nutrimx** corre su acceso con Google y
|
|
832
997
|
Facebook sobre este módulo en producción. El resto sigue sin ejercitarse de verdad.
|
package/dist/bootstrap/index.cjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use strict";Object.defineProperty(exports, "__esModule", {value: true});
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
require('../chunk-TJHRABML.cjs');
|
|
3
|
+
var _chunkYARLPYG5cjs = require('../chunk-YARLPYG5.cjs');
|
|
5
4
|
require('../chunk-J7TURDAL.cjs');
|
|
5
|
+
require('../chunk-TJHRABML.cjs');
|
|
6
6
|
require('../chunk-ZA56XBCK.cjs');
|
|
7
7
|
require('../chunk-R7BVS6CI.cjs');
|
|
8
8
|
require('../chunk-2REOCMUD.cjs');
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
exports.applyNestKitDefaults =
|
|
11
|
+
exports.applyNestKitDefaults = _chunkYARLPYG5cjs.applyNestKitDefaults;
|
package/dist/bootstrap/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
applyNestKitDefaults
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-KDAA6GFF.js";
|
|
3
|
+
} from "../chunk-ANQ3YPDI.js";
|
|
5
4
|
import "../chunk-7I2Y7V52.js";
|
|
5
|
+
import "../chunk-KDAA6GFF.js";
|
|
6
6
|
import "../chunk-EBO6UKHL.js";
|
|
7
7
|
import "../chunk-YFYHLYHN.js";
|
|
8
8
|
import "../chunk-4MGIQFAJ.js";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
|
|
2
|
+
|
|
3
|
+
var _chunk2REOCMUDcjs = require('../../chunk-2REOCMUD.cjs');
|
|
4
|
+
|
|
5
|
+
// src/database/typeorm/typeorm.module.ts
|
|
6
|
+
var _common = require('@nestjs/common');
|
|
7
|
+
var _typeorm = require('typeorm');
|
|
8
|
+
var DATA_SOURCE = /* @__PURE__ */ Symbol("DATA_SOURCE");
|
|
9
|
+
function buildDataSource(options) {
|
|
10
|
+
const url = _nullishCoalesce(options.url, () => ( process.env.DATABASE_URL));
|
|
11
|
+
const hasHost = Boolean(options.host);
|
|
12
|
+
if (!url && !hasHost) {
|
|
13
|
+
throw new Error("TypeOrmDbModule: DATABASE_URL is not set and no host was provided.");
|
|
14
|
+
}
|
|
15
|
+
return new (0, _typeorm.DataSource)({
|
|
16
|
+
type: "postgres",
|
|
17
|
+
url,
|
|
18
|
+
synchronize: false,
|
|
19
|
+
...options
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
var TypeOrmDbModule = class {
|
|
23
|
+
static forRoot(options = {}) {
|
|
24
|
+
const provider = {
|
|
25
|
+
provide: DATA_SOURCE,
|
|
26
|
+
useFactory: async () => {
|
|
27
|
+
const dataSource = buildDataSource(options);
|
|
28
|
+
return dataSource.isInitialized ? dataSource : dataSource.initialize();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return { module: TypeOrmDbModule, providers: [provider], exports: [DATA_SOURCE] };
|
|
32
|
+
}
|
|
33
|
+
static forRootAsync(config) {
|
|
34
|
+
const provider = {
|
|
35
|
+
provide: DATA_SOURCE,
|
|
36
|
+
useFactory: async (...args) => {
|
|
37
|
+
const options = await config.useFactory(...args);
|
|
38
|
+
const dataSource = buildDataSource(options);
|
|
39
|
+
return dataSource.isInitialized ? dataSource : dataSource.initialize();
|
|
40
|
+
},
|
|
41
|
+
inject: _nullishCoalesce(config.inject, () => ( []))
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
module: TypeOrmDbModule,
|
|
45
|
+
imports: _nullishCoalesce(config.imports, () => ( [])),
|
|
46
|
+
providers: [provider],
|
|
47
|
+
exports: [DATA_SOURCE]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
TypeOrmDbModule = exports.TypeOrmDbModule = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
|
|
52
|
+
_common.Global.call(void 0, ),
|
|
53
|
+
_common.Module.call(void 0, {})
|
|
54
|
+
], TypeOrmDbModule);
|
|
55
|
+
|
|
56
|
+
// src/database/typeorm/tenant-scope.ts
|
|
57
|
+
async function withTenantScope(dataSource, session, fn) {
|
|
58
|
+
const runner = dataSource.createQueryRunner();
|
|
59
|
+
await runner.connect();
|
|
60
|
+
await runner.startTransaction();
|
|
61
|
+
try {
|
|
62
|
+
for (const [name, value] of Object.entries(session)) {
|
|
63
|
+
await runner.query("SELECT set_config($1, $2, true)", [name, value]);
|
|
64
|
+
}
|
|
65
|
+
const result = await fn(runner.manager);
|
|
66
|
+
await runner.commitTransaction();
|
|
67
|
+
return result;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await runner.rollbackTransaction();
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
await runner.release();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
exports.DATA_SOURCE = DATA_SOURCE; exports.TypeOrmDbModule = TypeOrmDbModule; exports.withTenantScope = withTenantScope;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__decorateClass
|
|
3
|
+
} from "../../chunk-4MGIQFAJ.js";
|
|
4
|
+
|
|
5
|
+
// src/database/typeorm/typeorm.module.ts
|
|
6
|
+
import { Global, Module } from "@nestjs/common";
|
|
7
|
+
import { DataSource } from "typeorm";
|
|
8
|
+
var DATA_SOURCE = /* @__PURE__ */ Symbol("DATA_SOURCE");
|
|
9
|
+
function buildDataSource(options) {
|
|
10
|
+
const url = options.url ?? process.env.DATABASE_URL;
|
|
11
|
+
const hasHost = Boolean(options.host);
|
|
12
|
+
if (!url && !hasHost) {
|
|
13
|
+
throw new Error("TypeOrmDbModule: DATABASE_URL is not set and no host was provided.");
|
|
14
|
+
}
|
|
15
|
+
return new DataSource({
|
|
16
|
+
type: "postgres",
|
|
17
|
+
url,
|
|
18
|
+
synchronize: false,
|
|
19
|
+
...options
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
var TypeOrmDbModule = class {
|
|
23
|
+
static forRoot(options = {}) {
|
|
24
|
+
const provider = {
|
|
25
|
+
provide: DATA_SOURCE,
|
|
26
|
+
useFactory: async () => {
|
|
27
|
+
const dataSource = buildDataSource(options);
|
|
28
|
+
return dataSource.isInitialized ? dataSource : dataSource.initialize();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return { module: TypeOrmDbModule, providers: [provider], exports: [DATA_SOURCE] };
|
|
32
|
+
}
|
|
33
|
+
static forRootAsync(config) {
|
|
34
|
+
const provider = {
|
|
35
|
+
provide: DATA_SOURCE,
|
|
36
|
+
useFactory: async (...args) => {
|
|
37
|
+
const options = await config.useFactory(...args);
|
|
38
|
+
const dataSource = buildDataSource(options);
|
|
39
|
+
return dataSource.isInitialized ? dataSource : dataSource.initialize();
|
|
40
|
+
},
|
|
41
|
+
inject: config.inject ?? []
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
module: TypeOrmDbModule,
|
|
45
|
+
imports: config.imports ?? [],
|
|
46
|
+
providers: [provider],
|
|
47
|
+
exports: [DATA_SOURCE]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
TypeOrmDbModule = __decorateClass([
|
|
52
|
+
Global(),
|
|
53
|
+
Module({})
|
|
54
|
+
], TypeOrmDbModule);
|
|
55
|
+
|
|
56
|
+
// src/database/typeorm/tenant-scope.ts
|
|
57
|
+
async function withTenantScope(dataSource, session, fn) {
|
|
58
|
+
const runner = dataSource.createQueryRunner();
|
|
59
|
+
await runner.connect();
|
|
60
|
+
await runner.startTransaction();
|
|
61
|
+
try {
|
|
62
|
+
for (const [name, value] of Object.entries(session)) {
|
|
63
|
+
await runner.query("SELECT set_config($1, $2, true)", [name, value]);
|
|
64
|
+
}
|
|
65
|
+
const result = await fn(runner.manager);
|
|
66
|
+
await runner.commitTransaction();
|
|
67
|
+
return result;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await runner.rollbackTransaction();
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
await runner.release();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export {
|
|
76
|
+
DATA_SOURCE,
|
|
77
|
+
TypeOrmDbModule,
|
|
78
|
+
withTenantScope
|
|
79
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DataSource, EntityManager } from 'typeorm';
|
|
2
|
+
/**
|
|
3
|
+
* Runs `fn` inside a transaction whose Postgres session variables are set first,
|
|
4
|
+
* so Row-Level-Security policies keyed on those variables see the right tenant.
|
|
5
|
+
* This is the TypeORM equivalent of the `pg` `withTenantScope`: the ORM's own
|
|
6
|
+
* pool would otherwise run each query on an arbitrary connection with no session
|
|
7
|
+
* context, and RLS would see nothing (or the wrong rows).
|
|
8
|
+
*
|
|
9
|
+
* The `EntityManager` handed to `fn` is bound to that one connection and
|
|
10
|
+
* transaction — so `manager.getRepository(X).find()` inside runs scoped, and
|
|
11
|
+
* anything outside `fn` does NOT. Never leak the manager past the callback.
|
|
12
|
+
*
|
|
13
|
+
* `session` maps GUC name → value, e.g.
|
|
14
|
+
* `{ 'request.jwt.claim.sub': userId, 'request.jwt.claim.tenant': tenantId }`.
|
|
15
|
+
* Each is applied with `set_config(name, value, true)` (transaction-local), which
|
|
16
|
+
* is why the whole thing has to be one transaction on one connection.
|
|
17
|
+
*/
|
|
18
|
+
export declare function withTenantScope<T>(dataSource: DataSource, session: Record<string, string>, fn: (manager: EntityManager) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
import { type DataSourceOptions } from 'typeorm';
|
|
3
|
+
/** DI token for the shared TypeORM {@link DataSource}. Inject with `@Inject(DATA_SOURCE)`. */
|
|
4
|
+
export declare const DATA_SOURCE: unique symbol;
|
|
5
|
+
/**
|
|
6
|
+
* Options for the DataSource. Everything TypeORM's Postgres options accept, minus
|
|
7
|
+
* the bits this module fixes: `type` is always `postgres`, and `synchronize` is
|
|
8
|
+
* forced off (schema changes go through migrations, never an auto-sync that would
|
|
9
|
+
* happily drop a column in production). `url` defaults to `DATABASE_URL`.
|
|
10
|
+
*/
|
|
11
|
+
export type TypeOrmDbOptions = Partial<Omit<DataSourceOptions, 'type' | 'synchronize'>>;
|
|
12
|
+
/**
|
|
13
|
+
* Provides a single, initialized TypeORM {@link DataSource} for the app, on
|
|
14
|
+
* Postgres (works with any Postgres, including Neon). Register once in the root
|
|
15
|
+
* module; inject the DataSource with `@Inject(DATA_SOURCE)`.
|
|
16
|
+
*
|
|
17
|
+
* For multi-tenant apps that rely on Postgres RLS, pair it with `withTenantScope`
|
|
18
|
+
* (same folder): the ORM pool alone has no per-request session, so tenant-scoped
|
|
19
|
+
* work must go through that helper.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* TypeOrmDbModule.forRoot({ entities: [User, Order], migrations: ['dist/migrations/*.js'] })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* `synchronize` is always off and `type` is always `postgres` — schema changes go
|
|
26
|
+
* through migrations. Install `typeorm` (optional peer dep) to use this module.
|
|
27
|
+
*/
|
|
28
|
+
export declare class TypeOrmDbModule {
|
|
29
|
+
static forRoot(options?: TypeOrmDbOptions): DynamicModule;
|
|
30
|
+
static forRootAsync(config: {
|
|
31
|
+
imports?: any[];
|
|
32
|
+
inject?: any[];
|
|
33
|
+
useFactory: (...args: any[]) => TypeOrmDbOptions | Promise<TypeOrmDbOptions>;
|
|
34
|
+
}): DynamicModule;
|
|
35
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -9,6 +9,7 @@ var _chunkSPTDXUEDcjs = require('./chunk-SPTDXUED.cjs');
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
var _chunkRF75KC63cjs = require('./chunk-RF75KC63.cjs');
|
|
12
|
+
require('./chunk-7SOM7EZP.cjs');
|
|
12
13
|
require('./chunk-MR2IFCZE.cjs');
|
|
13
14
|
|
|
14
15
|
|
|
@@ -17,38 +18,37 @@ require('./chunk-MR2IFCZE.cjs');
|
|
|
17
18
|
var _chunkV2M75FN3cjs = require('./chunk-V2M75FN3.cjs');
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
var _chunk54ZXIB5Tcjs = require('./chunk-54ZXIB5T.cjs');
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
|
|
24
23
|
|
|
25
24
|
|
|
25
|
+
var _chunkFDNGAYTZcjs = require('./chunk-FDNGAYTZ.cjs');
|
|
26
26
|
|
|
27
|
-
var _chunk3CLYZC3Tcjs = require('./chunk-3CLYZC3T.cjs');
|
|
28
27
|
|
|
28
|
+
var _chunkYARLPYG5cjs = require('./chunk-YARLPYG5.cjs');
|
|
29
29
|
|
|
30
|
-
var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
|
|
31
30
|
|
|
31
|
+
var _chunkJ7TURDALcjs = require('./chunk-J7TURDAL.cjs');
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
var
|
|
35
|
-
require('./chunk-7SOM7EZP.cjs');
|
|
34
|
+
var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
|
|
36
35
|
|
|
37
36
|
|
|
37
|
+
var _chunkZA56XBCKcjs = require('./chunk-ZA56XBCK.cjs');
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
var _chunkR7BVS6CIcjs = require('./chunk-R7BVS6CI.cjs');
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
var _chunkFDNGAYTZcjs = require('./chunk-FDNGAYTZ.cjs');
|
|
43
43
|
|
|
44
|
+
var _chunk3CLYZC3Tcjs = require('./chunk-3CLYZC3T.cjs');
|
|
44
45
|
|
|
45
|
-
var _chunkJ7TURDALcjs = require('./chunk-J7TURDAL.cjs');
|
|
46
46
|
|
|
47
|
+
var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
|
|
47
48
|
|
|
48
|
-
var _chunkZA56XBCKcjs = require('./chunk-ZA56XBCK.cjs');
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
var
|
|
51
|
+
var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
|
|
52
52
|
require('./chunk-2REOCMUD.cjs');
|
|
53
53
|
|
|
54
54
|
|
|
@@ -75,4 +75,4 @@ require('./chunk-2REOCMUD.cjs');
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
exports.AppException = _chunkR7BVS6CIcjs.AppException; exports.BaseCrudService = _chunk3CLYZC3Tcjs.BaseCrudService; exports.BaseResponseDto = _chunkQS2W5XCQcjs.BaseResponseDto; exports.ConflictAppException = _chunkFDNGAYTZcjs.ConflictAppException; exports.ForbiddenAppException = _chunkFDNGAYTZcjs.ForbiddenAppException; exports.GlobalExceptionFilter = _chunkJ7TURDALcjs.GlobalExceptionFilter; exports.I18nModule = _chunkM3EL5O6Tcjs.I18nModule; exports.NotFoundAppException = _chunkFDNGAYTZcjs.NotFoundAppException; exports.PG_POOL = _chunkRF75KC63cjs.PG_POOL; exports.PgModule = _chunkRF75KC63cjs.PgModule; exports.REQUEST_ID_HEADER = _chunkV2M75FN3cjs.REQUEST_ID_HEADER; exports.RequestContext = _chunkZA56XBCKcjs.RequestContext; exports.RequestIdMiddleware = _chunkV2M75FN3cjs.RequestIdMiddleware; exports.ResponseInterceptor = _chunkTJHRABMLcjs.ResponseInterceptor; exports.SUPABASE_ANON_CLIENT = _chunkSPTDXUEDcjs.SUPABASE_ANON_CLIENT; exports.SUPABASE_SERVICE_ROLE_CLIENT = _chunkSPTDXUEDcjs.SUPABASE_SERVICE_ROLE_CLIENT; exports.SupabaseModule = _chunkSPTDXUEDcjs.SupabaseModule; exports.TrackingModule = _chunkV2M75FN3cjs.TrackingModule; exports.UnauthorizedAppException = _chunkFDNGAYTZcjs.UnauthorizedAppException; exports.ValidationAppException = _chunkFDNGAYTZcjs.ValidationAppException; exports.applyNestKitDefaults =
|
|
78
|
+
exports.AppException = _chunkR7BVS6CIcjs.AppException; exports.BaseCrudService = _chunk3CLYZC3Tcjs.BaseCrudService; exports.BaseResponseDto = _chunkQS2W5XCQcjs.BaseResponseDto; exports.ConflictAppException = _chunkFDNGAYTZcjs.ConflictAppException; exports.ForbiddenAppException = _chunkFDNGAYTZcjs.ForbiddenAppException; exports.GlobalExceptionFilter = _chunkJ7TURDALcjs.GlobalExceptionFilter; exports.I18nModule = _chunkM3EL5O6Tcjs.I18nModule; exports.NotFoundAppException = _chunkFDNGAYTZcjs.NotFoundAppException; exports.PG_POOL = _chunkRF75KC63cjs.PG_POOL; exports.PgModule = _chunkRF75KC63cjs.PgModule; exports.REQUEST_ID_HEADER = _chunkV2M75FN3cjs.REQUEST_ID_HEADER; exports.RequestContext = _chunkZA56XBCKcjs.RequestContext; exports.RequestIdMiddleware = _chunkV2M75FN3cjs.RequestIdMiddleware; exports.ResponseInterceptor = _chunkTJHRABMLcjs.ResponseInterceptor; exports.SUPABASE_ANON_CLIENT = _chunkSPTDXUEDcjs.SUPABASE_ANON_CLIENT; exports.SUPABASE_SERVICE_ROLE_CLIENT = _chunkSPTDXUEDcjs.SUPABASE_SERVICE_ROLE_CLIENT; exports.SupabaseModule = _chunkSPTDXUEDcjs.SupabaseModule; exports.TrackingModule = _chunkV2M75FN3cjs.TrackingModule; exports.UnauthorizedAppException = _chunkFDNGAYTZcjs.UnauthorizedAppException; exports.ValidationAppException = _chunkFDNGAYTZcjs.ValidationAppException; exports.applyNestKitDefaults = _chunkYARLPYG5cjs.applyNestKitDefaults; exports.createCrudController = _chunk3CLYZC3Tcjs.createCrudController; exports.translateOr = _chunkM3EL5O6Tcjs.translateOr; exports.withTenantScope = _chunkRF75KC63cjs.withTenantScope;
|
package/dist/index.js
CHANGED
|
@@ -9,30 +9,13 @@ import {
|
|
|
9
9
|
PgModule,
|
|
10
10
|
withTenantScope
|
|
11
11
|
} from "./chunk-VKOPDDCC.js";
|
|
12
|
+
import "./chunk-DQYAIQQ5.js";
|
|
12
13
|
import "./chunk-NAK4WDKS.js";
|
|
13
14
|
import {
|
|
14
15
|
REQUEST_ID_HEADER,
|
|
15
16
|
RequestIdMiddleware,
|
|
16
17
|
TrackingModule
|
|
17
18
|
} from "./chunk-EYURGACO.js";
|
|
18
|
-
import {
|
|
19
|
-
applyNestKitDefaults
|
|
20
|
-
} from "./chunk-AOCF5QCZ.js";
|
|
21
|
-
import {
|
|
22
|
-
ResponseInterceptor
|
|
23
|
-
} from "./chunk-KDAA6GFF.js";
|
|
24
|
-
import {
|
|
25
|
-
BaseCrudService,
|
|
26
|
-
createCrudController
|
|
27
|
-
} from "./chunk-JOVBJDJ2.js";
|
|
28
|
-
import {
|
|
29
|
-
BaseResponseDto
|
|
30
|
-
} from "./chunk-XX2HPTRU.js";
|
|
31
|
-
import {
|
|
32
|
-
I18nModule,
|
|
33
|
-
translateOr
|
|
34
|
-
} from "./chunk-IYUUYCP5.js";
|
|
35
|
-
import "./chunk-DQYAIQQ5.js";
|
|
36
19
|
import {
|
|
37
20
|
ConflictAppException,
|
|
38
21
|
ForbiddenAppException,
|
|
@@ -40,15 +23,32 @@ import {
|
|
|
40
23
|
UnauthorizedAppException,
|
|
41
24
|
ValidationAppException
|
|
42
25
|
} from "./chunk-ORWJ7LES.js";
|
|
26
|
+
import {
|
|
27
|
+
applyNestKitDefaults
|
|
28
|
+
} from "./chunk-ANQ3YPDI.js";
|
|
43
29
|
import {
|
|
44
30
|
GlobalExceptionFilter
|
|
45
31
|
} from "./chunk-7I2Y7V52.js";
|
|
32
|
+
import {
|
|
33
|
+
ResponseInterceptor
|
|
34
|
+
} from "./chunk-KDAA6GFF.js";
|
|
46
35
|
import {
|
|
47
36
|
RequestContext
|
|
48
37
|
} from "./chunk-EBO6UKHL.js";
|
|
49
38
|
import {
|
|
50
39
|
AppException
|
|
51
40
|
} from "./chunk-YFYHLYHN.js";
|
|
41
|
+
import {
|
|
42
|
+
BaseCrudService,
|
|
43
|
+
createCrudController
|
|
44
|
+
} from "./chunk-JOVBJDJ2.js";
|
|
45
|
+
import {
|
|
46
|
+
BaseResponseDto
|
|
47
|
+
} from "./chunk-XX2HPTRU.js";
|
|
48
|
+
import {
|
|
49
|
+
I18nModule,
|
|
50
|
+
translateOr
|
|
51
|
+
} from "./chunk-IYUUYCP5.js";
|
|
52
52
|
import "./chunk-4MGIQFAJ.js";
|
|
53
53
|
export {
|
|
54
54
|
AppException,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miguelmorales13/nestkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
"import": "./dist/database/postgres/index.js",
|
|
52
52
|
"require": "./dist/database/postgres/index.cjs"
|
|
53
53
|
},
|
|
54
|
+
"./database/typeorm": {
|
|
55
|
+
"types": "./dist/database/typeorm/index.d.ts",
|
|
56
|
+
"import": "./dist/database/typeorm/index.js",
|
|
57
|
+
"require": "./dist/database/typeorm/index.cjs"
|
|
58
|
+
},
|
|
54
59
|
"./database/supabase": {
|
|
55
60
|
"types": "./dist/database/supabase/index.d.ts",
|
|
56
61
|
"import": "./dist/database/supabase/index.js",
|
|
@@ -149,7 +154,8 @@
|
|
|
149
154
|
"nodemailer": "^9.0.5",
|
|
150
155
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
151
156
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
152
|
-
"@aws-sdk/lib-storage": "^3.0.0"
|
|
157
|
+
"@aws-sdk/lib-storage": "^3.0.0",
|
|
158
|
+
"typeorm": "^0.3.0 || ^1.0.0"
|
|
153
159
|
},
|
|
154
160
|
"peerDependenciesMeta": {
|
|
155
161
|
"@aws-sdk/client-s3": {
|
|
@@ -190,6 +196,9 @@
|
|
|
190
196
|
},
|
|
191
197
|
"nodemailer": {
|
|
192
198
|
"optional": true
|
|
199
|
+
},
|
|
200
|
+
"typeorm": {
|
|
201
|
+
"optional": true
|
|
193
202
|
}
|
|
194
203
|
},
|
|
195
204
|
"dependencies": {
|
|
@@ -222,6 +231,7 @@
|
|
|
222
231
|
"stripe": "^22.5.0",
|
|
223
232
|
"telegraf": "^4.16.3",
|
|
224
233
|
"tsup": "^8.5.1",
|
|
234
|
+
"typeorm": "^1.1.0",
|
|
225
235
|
"typescript": "^5.6.0"
|
|
226
236
|
},
|
|
227
237
|
"overrides": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var _chunkJ7TURDALcjs = require('./chunk-J7TURDAL.cjs');
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
var
|
|
6
|
+
var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
|
|
7
7
|
|
|
8
8
|
// src/bootstrap/apply-defaults.ts
|
|
9
9
|
|