@stjoseph/prumo 0.0.1
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/.prumo-templates/api/auth.md +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- package/templates/workspace/tsconfig.base.json +10 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { Injectable } from '@nestjs/common'
|
|
3
|
+
|
|
4
|
+
type Store = { requestId: string }
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class RequestContextService {
|
|
8
|
+
private readonly storage = new AsyncLocalStorage<Store>()
|
|
9
|
+
|
|
10
|
+
run<T>(requestId: string, callback: () => T): T {
|
|
11
|
+
return this.storage.run({ requestId }, callback)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get requestId(): string | undefined {
|
|
15
|
+
return this.storage.getStore()?.requestId
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { Injectable, type NestMiddleware } from '@nestjs/common'
|
|
3
|
+
import type { NextFunction, Request, Response } from 'express'
|
|
4
|
+
import { RequestContextService } from './request-context.service'
|
|
5
|
+
|
|
6
|
+
export const REQUEST_ID_HEADER = 'x-request-id'
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class RequestIdMiddleware implements NestMiddleware {
|
|
10
|
+
constructor(private readonly context: RequestContextService) {}
|
|
11
|
+
|
|
12
|
+
use(request: Request, response: Response, next: NextFunction): void {
|
|
13
|
+
const inbound = request.header(REQUEST_ID_HEADER)
|
|
14
|
+
const requestId = inbound && inbound.length > 0 ? inbound : randomUUID()
|
|
15
|
+
|
|
16
|
+
response.setHeader(REQUEST_ID_HEADER, requestId)
|
|
17
|
+
this.context.run(requestId, next)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
|
|
2
|
+
|
|
3
|
+
const LOCALES = ['en', 'pt-BR'] as const
|
|
4
|
+
|
|
5
|
+
export class UpdateProfileDto {
|
|
6
|
+
@IsOptional()
|
|
7
|
+
@IsString()
|
|
8
|
+
@MinLength(1)
|
|
9
|
+
@MaxLength(80)
|
|
10
|
+
displayName?: string
|
|
11
|
+
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsIn(LOCALES)
|
|
14
|
+
locale?: (typeof LOCALES)[number]
|
|
15
|
+
|
|
16
|
+
@IsOptional()
|
|
17
|
+
@IsString()
|
|
18
|
+
@MaxLength(64)
|
|
19
|
+
timezone?: string
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineEntity, p } from '@mikro-orm/core'
|
|
2
|
+
|
|
3
|
+
const ProfileSchema = defineEntity({
|
|
4
|
+
name: 'Profile',
|
|
5
|
+
properties: {
|
|
6
|
+
id: p.uuid().primary().defaultRaw('uuidv7()'),
|
|
7
|
+
userId: p.uuid().unique(),
|
|
8
|
+
displayName: p.text(),
|
|
9
|
+
locale: p.text().default('en'),
|
|
10
|
+
timezone: p.text().default('UTC'),
|
|
11
|
+
createdAt: p
|
|
12
|
+
.datetime()
|
|
13
|
+
.onCreate(() => new Date())
|
|
14
|
+
.defaultRaw('now()'),
|
|
15
|
+
updatedAt: p
|
|
16
|
+
.datetime()
|
|
17
|
+
.onCreate(() => new Date())
|
|
18
|
+
.onUpdate(() => new Date())
|
|
19
|
+
.defaultRaw('now()'),
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export class Profile extends ProfileSchema.class {}
|
|
24
|
+
|
|
25
|
+
ProfileSchema.setClass(Profile)
|
|
26
|
+
|
|
27
|
+
export { ProfileSchema }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Body, Controller, Get, Patch } from '@nestjs/common'
|
|
2
|
+
import { ApiTags } from '@nestjs/swagger'
|
|
3
|
+
import type { AuthUser } from '@/auth/auth.factory'
|
|
4
|
+
import { CurrentUser } from '@/auth/current-user.decorator'
|
|
5
|
+
import { UpdateProfileDto } from './dto/update-profile.dto'
|
|
6
|
+
import type { Profile } from './entities/profile.entity'
|
|
7
|
+
import { UsersService } from './users.service'
|
|
8
|
+
|
|
9
|
+
@ApiTags('users')
|
|
10
|
+
@Controller({ path: 'users', version: '1' })
|
|
11
|
+
export class UsersController {
|
|
12
|
+
constructor(private readonly users: UsersService) {}
|
|
13
|
+
|
|
14
|
+
@Get('me')
|
|
15
|
+
findMe(@CurrentUser() user: AuthUser): Promise<Profile> {
|
|
16
|
+
return this.users.findProfile(user.id)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Patch('me')
|
|
20
|
+
updateMe(@CurrentUser() user: AuthUser, @Body() changes: UpdateProfileDto): Promise<Profile> {
|
|
21
|
+
return this.users.updateProfile(user.id, changes)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common'
|
|
2
|
+
import { UsersController } from './users.controller'
|
|
3
|
+
import { UsersService } from './users.service'
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
controllers: [UsersController],
|
|
7
|
+
providers: [UsersService],
|
|
8
|
+
exports: [UsersService],
|
|
9
|
+
})
|
|
10
|
+
export class UsersModule {}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NotFoundException } from '@nestjs/common'
|
|
2
|
+
import { plainToInstance } from 'class-transformer'
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { createProfile } from '../../test/factories/profile.factory'
|
|
5
|
+
import { testOrm } from '../../test/setup'
|
|
6
|
+
import { UpdateProfileDto } from './dto/update-profile.dto'
|
|
7
|
+
import { UsersService } from './users.service'
|
|
8
|
+
|
|
9
|
+
describe('UsersService', () => {
|
|
10
|
+
let service: UsersService
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
service = new UsersService(testOrm().em.fork())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('finds the profile belonging to a user', async () => {
|
|
17
|
+
const em = testOrm().em.fork()
|
|
18
|
+
const created = await createProfile(em, { displayName: 'Ana' })
|
|
19
|
+
|
|
20
|
+
await expect(service.findProfile(created.userId)).resolves.toMatchObject({
|
|
21
|
+
displayName: 'Ana',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('refuses a user without a profile', async () => {
|
|
26
|
+
await expect(service.findProfile(crypto.randomUUID())).rejects.toBeInstanceOf(NotFoundException)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('applies only the fields the request carries', async () => {
|
|
30
|
+
const em = testOrm().em.fork()
|
|
31
|
+
const created = await createProfile(em, {
|
|
32
|
+
displayName: 'Ana',
|
|
33
|
+
timezone: 'UTC',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const changes = plainToInstance(UpdateProfileDto, { timezone: 'America/Sao_Paulo' })
|
|
37
|
+
const updated = await service.updateProfile(created.userId, changes)
|
|
38
|
+
|
|
39
|
+
expect(updated).toMatchObject({
|
|
40
|
+
displayName: 'Ana',
|
|
41
|
+
timezone: 'America/Sao_Paulo',
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { Injectable, NotFoundException } from '@nestjs/common'
|
|
3
|
+
import type { UpdateProfileDto } from './dto/update-profile.dto'
|
|
4
|
+
import { Profile } from './entities/profile.entity'
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class UsersService {
|
|
8
|
+
constructor(private readonly em: EntityManager) {}
|
|
9
|
+
|
|
10
|
+
async findProfile(userId: string): Promise<Profile> {
|
|
11
|
+
const profile = await this.em.findOne(Profile, { userId })
|
|
12
|
+
|
|
13
|
+
if (profile === null) {
|
|
14
|
+
throw new NotFoundException('Profile not found')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return profile
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async updateProfile(userId: string, changes: UpdateProfileDto): Promise<Profile> {
|
|
21
|
+
const profile = await this.findProfile(userId)
|
|
22
|
+
|
|
23
|
+
this.em.assign(profile, changes, { ignoreUndefined: true })
|
|
24
|
+
await this.em.flush()
|
|
25
|
+
|
|
26
|
+
return profile
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async createProfile(userId: string, displayName: string): Promise<Profile> {
|
|
30
|
+
const profile = this.em.create(Profile, { userId, displayName })
|
|
31
|
+
|
|
32
|
+
await this.em.flush()
|
|
33
|
+
|
|
34
|
+
return profile
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { Profile } from '@/users/entities/profile.entity'
|
|
3
|
+
import { createUser } from './user.factory'
|
|
4
|
+
|
|
5
|
+
type ProfileOverrides = Partial<{
|
|
6
|
+
userId: string
|
|
7
|
+
displayName: string
|
|
8
|
+
locale: string
|
|
9
|
+
timezone: string
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
export async function createProfile(
|
|
13
|
+
em: EntityManager,
|
|
14
|
+
overrides: ProfileOverrides = {},
|
|
15
|
+
): Promise<Profile> {
|
|
16
|
+
const profile = em.create(Profile, {
|
|
17
|
+
userId: overrides.userId ?? (await createUser(em)),
|
|
18
|
+
displayName: overrides.displayName ?? 'Test Person',
|
|
19
|
+
locale: overrides.locale ?? 'en',
|
|
20
|
+
timezone: overrides.timezone ?? 'UTC',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
await em.flush()
|
|
24
|
+
|
|
25
|
+
return profile
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
|
|
4
|
+
type UserOverrides = Partial<{ name: string; email: string }>
|
|
5
|
+
|
|
6
|
+
export async function createUser(
|
|
7
|
+
em: EntityManager,
|
|
8
|
+
overrides: UserOverrides = {},
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
const id = randomUUID()
|
|
11
|
+
|
|
12
|
+
// auth.user belongs to Better Auth and has no entity here, so the QueryBuilder cannot reach it.
|
|
13
|
+
await em
|
|
14
|
+
.getConnection()
|
|
15
|
+
.execute(
|
|
16
|
+
'insert into auth."user" (id, name, email, "emailVerified", "createdAt", "updatedAt") values (?, ?, ?, false, now(), now())',
|
|
17
|
+
[id, overrides.name ?? 'Test Person', overrides.email ?? `${id}@example.com`],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return id
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'
|
|
2
|
+
import type { TestProject } from 'vitest/node'
|
|
3
|
+
|
|
4
|
+
let container: StartedPostgreSqlContainer
|
|
5
|
+
|
|
6
|
+
export async function setup(project: TestProject): Promise<void> {
|
|
7
|
+
container = await new PostgreSqlContainer('postgres:18-alpine').start()
|
|
8
|
+
|
|
9
|
+
project.provide('postgresUrl', container.getConnectionUri())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function teardown(): Promise<void> {
|
|
13
|
+
await container.stop()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module 'vitest' {
|
|
17
|
+
interface ProvidedContext {
|
|
18
|
+
postgresUrl: string
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { MikroORM } from '@mikro-orm/postgresql'
|
|
2
|
+
import { getMigrations } from 'better-auth/db/migration'
|
|
3
|
+
import { Client, type Pool } from 'pg'
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, inject } from 'vitest'
|
|
5
|
+
import { createAuth } from '../src/auth/auth.factory'
|
|
6
|
+
import { createOrmConfig } from '../src/mikro-orm.factory'
|
|
7
|
+
|
|
8
|
+
let orm: MikroORM
|
|
9
|
+
let databaseUrl: string
|
|
10
|
+
let tables: string[] = []
|
|
11
|
+
|
|
12
|
+
export function testOrm(): MikroORM {
|
|
13
|
+
return orm
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function testDatabaseUrl(): string {
|
|
17
|
+
return databaseUrl
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function createWorkerDatabase(baseUrl: string, name: string): Promise<string> {
|
|
21
|
+
const admin = new Client({ connectionString: baseUrl })
|
|
22
|
+
|
|
23
|
+
await admin.connect()
|
|
24
|
+
await admin.query(`DROP DATABASE IF EXISTS "${name}"`)
|
|
25
|
+
await admin.query(`CREATE DATABASE "${name}"`)
|
|
26
|
+
await admin.end()
|
|
27
|
+
|
|
28
|
+
const url = new URL(baseUrl)
|
|
29
|
+
url.pathname = `/${name}`
|
|
30
|
+
|
|
31
|
+
const worker = new Client({ connectionString: url.toString() })
|
|
32
|
+
|
|
33
|
+
await worker.connect()
|
|
34
|
+
await worker.query('CREATE SCHEMA IF NOT EXISTS auth')
|
|
35
|
+
await worker.end()
|
|
36
|
+
|
|
37
|
+
return url.toString()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function migrateAuth(databaseUrl: string): Promise<void> {
|
|
41
|
+
const auth = createAuth({
|
|
42
|
+
AUTH_DATABASE_URL: databaseUrl,
|
|
43
|
+
BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
44
|
+
BETTER_AUTH_URL: 'http://localhost',
|
|
45
|
+
WEB_ORIGIN: 'http://localhost:5173',
|
|
46
|
+
})
|
|
47
|
+
const { runMigrations } = await getMigrations(auth.options)
|
|
48
|
+
|
|
49
|
+
await runMigrations()
|
|
50
|
+
await (auth.options.database as Pool).end()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
const name = `test_${process.env.VITEST_WORKER_ID ?? '1'}`
|
|
55
|
+
const clientUrl = await createWorkerDatabase(inject('postgresUrl'), name)
|
|
56
|
+
|
|
57
|
+
databaseUrl = clientUrl
|
|
58
|
+
await migrateAuth(clientUrl)
|
|
59
|
+
|
|
60
|
+
orm = await MikroORM.init(createOrmConfig({ DATABASE_URL: clientUrl, NODE_ENV: 'test' }))
|
|
61
|
+
|
|
62
|
+
await orm.migrator.up()
|
|
63
|
+
|
|
64
|
+
const metadata = orm.getMetadata().getAll()
|
|
65
|
+
const own = Object.values(metadata)
|
|
66
|
+
.filter((meta) => meta.tableName !== undefined && meta.pivotTable !== true)
|
|
67
|
+
.map((meta) => `"${meta.tableName}"`)
|
|
68
|
+
|
|
69
|
+
tables = [...own, 'auth."user"', 'auth.session', 'auth.account', 'auth.verification']
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
if (tables.length > 0) {
|
|
74
|
+
await orm.em.getConnection().execute(`TRUNCATE ${tables.join(', ')} RESTART IDENTITY CASCADE`)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await orm.close()
|
|
80
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "nodenext",
|
|
4
|
+
"moduleResolution": "nodenext",
|
|
5
|
+
"target": "ES2023",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"removeComments": true,
|
|
11
|
+
"allowSyntheticDefaultImports": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"strict": true,
|
|
18
|
+
"strictPropertyInitialization": false,
|
|
19
|
+
"noUncheckedIndexedAccess": true,
|
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"paths": {
|
|
23
|
+
"@/*": ["./src/*"]
|
|
24
|
+
},
|
|
25
|
+
"types": ["vitest/globals", "node"]
|
|
26
|
+
},
|
|
27
|
+
"include": ["src", "test", "vitest.config.mts"],
|
|
28
|
+
"exclude": ["node_modules", "dist"]
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import swc from 'unplugin-swc'
|
|
2
|
+
import { defineConfig } from 'vitest/config'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [swc.vite({ module: { type: 'es6' } })],
|
|
6
|
+
resolve: {
|
|
7
|
+
alias: { '@': `${import.meta.dirname}/src` },
|
|
8
|
+
},
|
|
9
|
+
test: {
|
|
10
|
+
globals: true,
|
|
11
|
+
include: ['src/**/*.spec.ts'],
|
|
12
|
+
globalSetup: ['./test/global-setup.ts'],
|
|
13
|
+
setupFiles: ['./test/setup.ts'],
|
|
14
|
+
pool: 'forks',
|
|
15
|
+
coverage: { provider: 'v8', include: ['src/**'] },
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
EXPO_PUBLIC_API_URL=http://localhost:3000
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
function insideRepository() {
|
|
4
|
+
try {
|
|
5
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
|
|
6
|
+
return true
|
|
7
|
+
} catch (error) {
|
|
8
|
+
// No git, or no repository: an install from an archive or inside a container, where hooks have no use.
|
|
9
|
+
if (error.code === 'ENOENT' || error.status === 128) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
throw error
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (insideRepository()) {
|
|
17
|
+
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' })
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# mobile
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
Node 22.17 or later, pnpm, and the API running with `MOBILE_APP_SCHEME=app`. A simulator or a device with Expo Go
|
|
6
|
+
to open the app.
|
|
7
|
+
|
|
8
|
+
## Running it
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm install
|
|
12
|
+
cp .env.example .env # EXPO_PUBLIC_API_URL points at the API
|
|
13
|
+
pnpm start
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
|
|
17
|
+
|
|
18
|
+
## Everyday commands
|
|
19
|
+
|
|
20
|
+
| Command | What it does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `pnpm test` | Jest with `jest-expo`, rendering components against a fake transport; no API needed |
|
|
23
|
+
| `pnpm lint` | Biome |
|
|
24
|
+
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
25
|
+
| `pnpm bundle` | Produces the iOS and Android bundles. It reads `EXPO_PUBLIC_API_URL` from the environment, not from `.env`, and fails without it |
|
|
26
|
+
|
|
27
|
+
## Conventions
|
|
28
|
+
|
|
29
|
+
The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
|
|
30
|
+
changing how something is done.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConfigContext, ExpoConfig } from 'expo/config'
|
|
2
|
+
|
|
3
|
+
const REQUIRED = ['EXPO_PUBLIC_API_URL'] as const
|
|
4
|
+
|
|
5
|
+
export default ({ config }: ConfigContext): ExpoConfig => {
|
|
6
|
+
const missing = REQUIRED.filter((name) => !process.env[name])
|
|
7
|
+
|
|
8
|
+
if (missing.length > 0) {
|
|
9
|
+
throw new Error(`Missing environment variables: ${missing.join(', ')}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return config as ExpoConfig
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expo": {
|
|
3
|
+
"name": "mobile",
|
|
4
|
+
"slug": "mobile",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"orientation": "portrait",
|
|
7
|
+
"icon": "./assets/images/icon.png",
|
|
8
|
+
"scheme": "app",
|
|
9
|
+
"userInterfaceStyle": "automatic",
|
|
10
|
+
"ios": {
|
|
11
|
+
"icon": "./assets/expo.icon"
|
|
12
|
+
},
|
|
13
|
+
"android": {
|
|
14
|
+
"adaptiveIcon": {
|
|
15
|
+
"backgroundColor": "#E6F4FE",
|
|
16
|
+
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
|
17
|
+
"backgroundImage": "./assets/images/android-icon-background.png",
|
|
18
|
+
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
|
19
|
+
},
|
|
20
|
+
"predictiveBackGestureEnabled": false
|
|
21
|
+
},
|
|
22
|
+
"platforms": ["ios", "android"],
|
|
23
|
+
"plugins": [
|
|
24
|
+
"expo-router",
|
|
25
|
+
"expo-secure-store",
|
|
26
|
+
[
|
|
27
|
+
"expo-splash-screen",
|
|
28
|
+
{
|
|
29
|
+
"backgroundColor": "#208AEF",
|
|
30
|
+
"image": "./assets/images/splash-icon.png",
|
|
31
|
+
"imageWidth": 76
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
],
|
|
35
|
+
"experiments": {
|
|
36
|
+
"typedRoutes": true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
|
3
|
+
</svg>
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fill": {
|
|
3
|
+
"automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
|
4
|
+
},
|
|
5
|
+
"groups": [
|
|
6
|
+
{
|
|
7
|
+
"layers": [
|
|
8
|
+
{
|
|
9
|
+
"image-name": "expo-symbol 2.svg",
|
|
10
|
+
"name": "expo-symbol 2",
|
|
11
|
+
"position": {
|
|
12
|
+
"scale": 1,
|
|
13
|
+
"translation-in-points": [1.1008400065293245e-5, -16.046875]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"image-name": "grid.png",
|
|
18
|
+
"name": "grid"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"shadow": {
|
|
22
|
+
"kind": "neutral",
|
|
23
|
+
"opacity": 0.5
|
|
24
|
+
},
|
|
25
|
+
"translucency": {
|
|
26
|
+
"enabled": true,
|
|
27
|
+
"value": 0.5
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"supported-platforms": {
|
|
32
|
+
"circles": ["watchOS"],
|
|
33
|
+
"squares": "shared"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
|
|
3
|
+
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
|
4
|
+
"files": { "includes": ["**", "!dist", "!coverage", "!.expo", "!assets"] },
|
|
5
|
+
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
|
|
6
|
+
"linter": { "rules": { "preset": "recommended" } },
|
|
7
|
+
"javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } },
|
|
8
|
+
"overrides": [
|
|
9
|
+
{
|
|
10
|
+
"includes": ["src/global.css"],
|
|
11
|
+
"linter": {
|
|
12
|
+
"rules": {
|
|
13
|
+
"suspicious": {
|
|
14
|
+
// NativeWind 4 needs Tailwind 3's @tailwind directives; Biome only knows Tailwind 4's syntax.
|
|
15
|
+
"noUnknownAtRules": "off"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css'
|