Skip to main content

Set up polymorphic associations between LocalizedText and Host/Place/Room

Commit Message

Set up polymorphic associations between LocalizedText and Host/Place/Room

Create a default LocalizedText for each Host/Place/Room

```console
pnpm psy db:migrate
pnpm uspec spec/unit/models
```

Also flesh out the LocalizedText controller. Since LocalizedText belongs to different types (Host/Place/Room), restricting the controller to only allow access to owned LocalizedText requires a different strategy. The switch statement used is future proofed so that if LocalizedText is added to another model in the future, access will automatically be restricted until it is explicitly added to the controller. Note that not-found (404) is a design choice that aligns with the not-found response returned when associationQuery(...).findOrFail(...) is used to limit access to owned resources; however, forbidden (403) could have been used instead.

```console
pnpm uspec spec/unit/controllers/V1/Host/LocalizedTextsController.spec.ts
```

Before committing, ensure all specs pass:

```console
pnpm uspec
```

Changes

diff --git a/api/spec/factories/LocalizedTextFactory.ts b/api/spec/factories/LocalizedTextFactory.ts
index 0df341b..163e3ff 100644
--- a/api/spec/factories/LocalizedTextFactory.ts
+++ b/api/spec/factories/LocalizedTextFactory.ts
@@ -1,10 +1,12 @@
import { UpdateableProperties } from '@rvoh/dream/types'
import LocalizedText from '@models/LocalizedText.js'
+import createPlace from '@spec/factories/PlaceFactory.js'

let counter = 0

export default async function createLocalizedText(attrs: UpdateableProperties<LocalizedText> = {}) {
return await LocalizedText.create({
+ localizable: attrs.localizable ? null : await createPlace(),
locale: 'en-US',
title: `LocalizedText title ${++counter}`,
markdown: `LocalizedText markdown ${counter}`,
diff --git a/api/spec/unit/controllers/V1/Host/LocalizedTextsController.spec.ts b/api/spec/unit/controllers/V1/Host/LocalizedTextsController.spec.ts
index c550e8f..0eecf7a 100644
--- a/api/spec/unit/controllers/V1/Host/LocalizedTextsController.spec.ts
+++ b/api/spec/unit/controllers/V1/Host/LocalizedTextsController.spec.ts
@@ -1,6 +1,9 @@
import LocalizedText from '@models/LocalizedText.js'
import User from '@models/User.js'
-import createLocalizedText from '@spec/factories/LocalizedTextFactory.js'
+import createHost from '@spec/factories/HostFactory.js'
+import createHostPlace from '@spec/factories/HostPlaceFactory.js'
+import createPlace from '@spec/factories/PlaceFactory.js'
+import createDen from '@spec/factories/Room/DenFactory.js'
import createUser from '@spec/factories/UserFactory.js'
import { RequestBody, session, SpecRequestType } from '@spec/unit/helpers/authentication.js'

@@ -13,72 +16,229 @@ describe('V1/Host/LocalizedTextsController', () => {
request = await session(user)
})

- describe('PATCH update', () => {
- const update = async <StatusCode extends 204 | 400 | 404>(
- localizedText: LocalizedText,
- data: RequestBody<'patch', '/v1/host/localized-texts/{id}'>,
- expectedStatus: StatusCode
- ) => {
- return request.patch('/v1/host/localized-texts/{id}', expectedStatus, {
- id: localizedText.id,
- data,
- })
- }
+ const update = async <StatusCode extends 204 | 400 | 404>(
+ localizedText: LocalizedText,
+ data: RequestBody<'patch', '/v1/host/localized-texts/{id}'>,
+ expectedStatus: StatusCode,
+ ) => {
+ return request.patch('/v1/host/localized-texts/{id}', expectedStatus, {
+ id: localizedText.id,
+ data,
+ })
+ }
+
+ const destroy = async <StatusCode extends 204 | 400 | 404>(
+ localizedText: LocalizedText,
+ expectedStatus: StatusCode,
+ ) => {
+ return request.delete('/v1/host/localized-texts/{id}', expectedStatus, {
+ id: localizedText.id,
+ })
+ }

+ context('a LocalizedText belonging to the current Host', () => {
it('updates the LocalizedText', async () => {
- const localizedText = await createLocalizedText({ user })
+ const host = await createHost({ user })
+ const localizedText = await host.associationQuery('localizedTexts').firstOrFail()

- await update(localizedText, {
- locale: 'es-ES',
- title: 'Updated LocalizedText title',
- markdown: 'Updated LocalizedText markdown',
- }, 204)
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Host title',
+ markdown: 'Updated Host markdown',
+ },
+ 204,
+ )

await localizedText.reload()
expect(localizedText.locale).toEqual('es-ES')
- expect(localizedText.title).toEqual('Updated LocalizedText title')
- expect(localizedText.markdown).toEqual('Updated LocalizedText markdown')
+ expect(localizedText.title).toEqual('Updated Host title')
+ expect(localizedText.markdown).toEqual('Updated Host markdown')
+ })
+
+ it('deletes the LocalizedText', async () => {
+ const host = await createHost({ user })
+ const localizedText = await host.associationQuery('localizedTexts').firstOrFail()
+
+ await destroy(localizedText, 204)
+
+ expect(await LocalizedText.find(localizedText.id)).toBeNull()
})

- context('a LocalizedText created by another User', () => {
+ context('a LocalizedText belonging to another Host', () => {
it('is not updated', async () => {
- const localizedText = await createLocalizedText()
+ await createHost({ user })
+ const host = await createHost()
+ const localizedText = await host.associationQuery('localizedTexts').firstOrFail()
const originalLocale = localizedText.locale
const originalTitle = localizedText.title
const originalMarkdown = localizedText.markdown

- await update(localizedText, {
- locale: 'es-ES',
- title: 'Updated LocalizedText title',
- markdown: 'Updated LocalizedText markdown',
- }, 404)
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Host title',
+ markdown: 'Updated Host markdown',
+ },
+ 404,
+ )

await localizedText.reload()
expect(localizedText.locale).toEqual(originalLocale)
expect(localizedText.title).toEqual(originalTitle)
expect(localizedText.markdown).toEqual(originalMarkdown)
})
+
+ it('is not deleted', async () => {
+ await createHost({ user })
+ const host = await createHost()
+ const localizedText = await host.associationQuery('localizedTexts').firstOrFail()
+
+ await destroy(localizedText, 404)
+
+ expect(await LocalizedText.find(localizedText.id)).toMatchDreamModel(localizedText)
+ })
})
})

- describe('DELETE destroy', () => {
- const destroy = async <StatusCode extends 204 | 400 | 404>(localizedText: LocalizedText, expectedStatus: StatusCode) => {
- return request.delete('/v1/host/localized-texts/{id}', expectedStatus, {
- id: localizedText.id,
+ context('a LocalizedText belonging to a Place for the current Host', () => {
+ it('updates the LocalizedText', async () => {
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ const localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Place title',
+ markdown: 'Updated Place markdown',
+ },
+ 204,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual('es-ES')
+ expect(localizedText.title).toEqual('Updated Place title')
+ expect(localizedText.markdown).toEqual('Updated Place markdown')
+ })
+
+ it('deletes the LocalizedText', async () => {
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ const localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+
+ await destroy(localizedText, 204)
+
+ expect(await LocalizedText.find(localizedText.id)).toBeNull()
+ })
+
+ context('a LocalizedText belonging to a Place for another Host', () => {
+ it('is not updated', async () => {
+ await createHost({ user })
+ const place = await createPlace()
+ const localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+ const originalLocale = localizedText.locale
+ const originalTitle = localizedText.title
+ const originalMarkdown = localizedText.markdown
+
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Place title',
+ markdown: 'Updated Place markdown',
+ },
+ 404,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual(originalLocale)
+ expect(localizedText.title).toEqual(originalTitle)
+ expect(localizedText.markdown).toEqual(originalMarkdown)
+ })
+
+ it('is not deleted', async () => {
+ await createHost({ user })
+ const place = await createPlace()
+ const localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+
+ await destroy(localizedText, 404)
+
+ expect(await LocalizedText.find(localizedText.id)).toMatchDreamModel(localizedText)
})
- }
+ })
+ })
+
+ context('a LocalizedText belonging to a Room for a Place for the current Host', () => {
+ it('updates the LocalizedText', async () => {
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ const room = await createDen({ place })
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()
+
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Room title',
+ markdown: 'Updated Room markdown',
+ },
+ 204,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual('es-ES')
+ expect(localizedText.title).toEqual('Updated Room title')
+ expect(localizedText.markdown).toEqual('Updated Room markdown')
+ })

it('deletes the LocalizedText', async () => {
- const localizedText = await createLocalizedText({ user })
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ const room = await createDen({ place })
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()

await destroy(localizedText, 204)

expect(await LocalizedText.find(localizedText.id)).toBeNull()
})

- context('a LocalizedText created by another User', () => {
+ context('a LocalizedText belonging to a Room for another Host', () => {
+ it('is not updated', async () => {
+ await createHost({ user })
+ const room = await createDen()
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()
+ const originalLocale = localizedText.locale
+ const originalTitle = localizedText.title
+ const originalMarkdown = localizedText.markdown
+
+ await update(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated Room title',
+ markdown: 'Updated Room markdown',
+ },
+ 404,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual(originalLocale)
+ expect(localizedText.title).toEqual(originalTitle)
+ expect(localizedText.markdown).toEqual(originalMarkdown)
+ })
+
it('is not deleted', async () => {
- const localizedText = await createLocalizedText()
+ await createHost({ user })
+ const room = await createDen()
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()

await destroy(localizedText, 404)

diff --git a/api/spec/unit/models/Host.spec.ts b/api/spec/unit/models/Host.spec.ts
index ef54e54..cdcb8d6 100644
--- a/api/spec/unit/models/Host.spec.ts
+++ b/api/spec/unit/models/Host.spec.ts
@@ -1,5 +1,6 @@
import createHost from '@spec/factories/HostFactory.js'
import createHostPlace from '@spec/factories/HostPlaceFactory.js'
+import createLocalizedText from '@spec/factories/LocalizedTextFactory.js'
import createPlace from '@spec/factories/PlaceFactory.js'

describe('Host', () => {
@@ -10,4 +11,21 @@ describe('Host', () => {

expect(await host.associationQuery('places').all()).toMatchDreamModels([place])
})
+
+ it('has many LocalizedTexts', async () => {
+ const host = await createHost()
+ const esLocalizedText = await createLocalizedText({ localizable: host, locale: 'es-ES' })
+
+ const localizedText = await host.associationQuery('localizedTexts', { and: { locale: 'es-ES' } }).last()
+ expect(localizedText).toMatchDreamModel(esLocalizedText)
+ })
+
+ context('upon creation', () => {
+ it('creates en-US LocalizedText for the Host', async () => {
+ const host = await createHost()
+ const localizedText = await host.associationQuery('localizedTexts').firstOrFail()
+
+ expect(localizedText.locale).toEqual('en-US')
+ })
+ })
})
diff --git a/api/spec/unit/models/Place.spec.ts b/api/spec/unit/models/Place.spec.ts
index 6315065..7399652 100644
--- a/api/spec/unit/models/Place.spec.ts
+++ b/api/spec/unit/models/Place.spec.ts
@@ -1,5 +1,6 @@
import createHost from '@spec/factories/HostFactory.js'
import createHostPlace from '@spec/factories/HostPlaceFactory.js'
+import createLocalizedText from '@spec/factories/LocalizedTextFactory.js'
import createPlace from '@spec/factories/PlaceFactory.js'

describe('Place', () => {
@@ -10,4 +11,22 @@ describe('Place', () => {

expect(await place.associationQuery('hosts').all()).toMatchDreamModels([host])
})
+
+ it('has many LocalizedTexts', async () => {
+ const place = await createPlace()
+ const esLocalizedText = await createLocalizedText({ localizable: place, locale: 'es-ES' })
+
+ const localizedText = await place.associationQuery('localizedTexts', { and: { locale: 'es-ES' } }).last()
+ expect(localizedText).toMatchDreamModel(esLocalizedText)
+ })
+
+ context('upon creation', () => {
+ it('creates en-US LocalizedText for the Place', async () => {
+ const place = await createPlace({ style: 'cottage' })
+ const localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+
+ expect(localizedText.locale).toEqual('en-US')
+ expect(localizedText.title).toEqual('My cottage')
+ })
+ })
})
diff --git a/api/spec/unit/models/Room.spec.ts b/api/spec/unit/models/Room.spec.ts
index 1e8d460..d888641 100644
--- a/api/spec/unit/models/Room.spec.ts
+++ b/api/spec/unit/models/Room.spec.ts
@@ -1,3 +1,21 @@
+import createLocalizedText from '@spec/factories/LocalizedTextFactory.js'
+import createDen from '@spec/factories/Room/DenFactory.js'
+
describe('Room', () => {
- it.todo('add a test here to get started building Room')
+ it('has many LocalizedTexts', async () => {
+ const room = await createDen()
+ const esLocalizedText = await createLocalizedText({ localizable: room, locale: 'es-ES' })
+
+ const localizedText = await room.associationQuery('localizedTexts', { and: { locale: 'es-ES' } }).last()
+ expect(localizedText).toMatchDreamModel(esLocalizedText)
+ })
+
+ context('upon creation', () => {
+ it('creates en-US LocalizedText for the Room', async () => {
+ const room = await createDen()
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()
+
+ expect(localizedText.locale).toEqual('en-US')
+ })
+ })
})
diff --git a/api/src/app/controllers/V1/Host/LocalizedTextsController.ts b/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
index af12ce7..b2404dc 100644
--- a/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
+++ b/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
@@ -1,7 +1,9 @@
import { OpenAPI } from '@rvoh/psychic'
import { DreamParamSafeColumnNames } from '@rvoh/dream/types'
+import Room from '@models/Room.js'
import V1HostBaseController from './BaseController.js'
import LocalizedText from '@models/LocalizedText.js'
+import { type LocalizedTypesEnum } from '@src/types/db.js'

const openApiTags = ['localized-texts']

@@ -18,9 +20,9 @@ export default class V1HostLocalizedTextsController extends V1HostBaseController
},
})
public async update() {
- // const localizedText = await this.localizedText()
- // await localizedText.update(this.extractParams(LocalizedText, paramSafeColumns))
- // this.noContent()
+ const localizedText = await this.localizedText()
+ await localizedText.update(this.extractParams(LocalizedText, paramSafeColumns))
+ this.noContent()
}

@OpenAPI({
@@ -30,14 +32,34 @@ export default class V1HostLocalizedTextsController extends V1HostBaseController
fastJsonStringify: true,
})
public async destroy() {
- // const localizedText = await this.localizedText()
- // await localizedText.destroy()
- // this.noContent()
+ const localizedText = await this.localizedText()
+ await localizedText.destroy()
+ this.noContent()
}

private async localizedText() {
- // return await this.currentUser.associationQuery('localizedTexts')
- // .preloadFor('default')
- // .findOrFail(this.castParam('id', 'string'))
+ const id = this.castParam('id', 'uuid')
+ const localizedText = await LocalizedText.findOrFail(id)
+ const localizableType: LocalizedTypesEnum = localizedText.localizableType
+
+ switch (localizableType) {
+ case 'Host':
+ return await this.currentHost.associationQuery('localizedTexts').preloadFor('default').findOrFail(id)
+ case 'Place': {
+ const place = await this.currentHost
+ .associationQuery('places')
+ .findOrFail(localizedText.localizableId)
+ return await place.associationQuery('localizedTexts').preloadFor('default').findOrFail(id)
+ }
+ case 'Room': {
+ const room = await Room.findOrFail(localizedText.localizableId)
+ await this.currentHost.associationQuery('places').findOrFail(room.placeId)
+ return await room.associationQuery('localizedTexts').preloadFor('default').findOrFail(id)
+ }
+ default: {
+ const _never: never = localizableType
+ throw new Error(`Unhandled LocalizedTypesEnum: ${String(_never)}`)
+ }
+ }
}
}
diff --git a/api/src/app/models/Host.ts b/api/src/app/models/Host.ts
index 5760f78..7e4b6b4 100644
--- a/api/src/app/models/Host.ts
+++ b/api/src/app/models/Host.ts
@@ -2,6 +2,7 @@ import { Decorators, SoftDelete } from '@rvoh/dream'
import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
import ApplicationModel from '@models/ApplicationModel.js'
import HostPlace from '@models/HostPlace.js'
+import LocalizedText from '@models/LocalizedText.js'
import Place from '@models/Place.js'
import User from '@models/User.js'

@@ -34,4 +35,12 @@ export default class Host extends ApplicationModel {

@deco.HasMany('Place', { through: 'hostPlaces' })
public places: Place[]
+
+ @deco.HasMany('LocalizedText', { polymorphic: true, on: 'localizableId', dependent: 'destroy' })
+ public localizedTexts: LocalizedText[]
+
+ @deco.AfterCreate()
+ public async createDefaultLocalizedText(this: Host) {
+ await this.createAssociation('localizedTexts', { locale: 'en-US' })
+ }
}
diff --git a/api/src/app/models/LocalizedText.ts b/api/src/app/models/LocalizedText.ts
index e0d99f2..1356de3 100644
--- a/api/src/app/models/LocalizedText.ts
+++ b/api/src/app/models/LocalizedText.ts
@@ -1,10 +1,11 @@
-import { SoftDelete } from '@rvoh/dream'
+import { Decorators, SoftDelete } from '@rvoh/dream'
import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
import ApplicationModel from '@models/ApplicationModel.js'
+import Host from '@models/Host.js'
+import Place from '@models/Place.js'
+import Room from '@models/Room.js'

-// Uncomment when adding decorators (@deco.BelongsTo, @deco.Validates, etc.):
-// import { Decorators } from '@rvoh/dream'
-// const deco = new Decorators<typeof LocalizedText>()
+const deco = new Decorators<typeof LocalizedText>()

@SoftDelete()
export default class LocalizedText extends ApplicationModel {
@@ -28,4 +29,7 @@ export default class LocalizedText extends ApplicationModel {
public createdAt: DreamColumn<LocalizedText, 'createdAt'>
public updatedAt: DreamColumn<LocalizedText, 'updatedAt'>
public deletedAt: DreamColumn<LocalizedText, 'deletedAt'>
+
+ @deco.BelongsTo(['Host', 'Place', 'Room'], { polymorphic: true, on: 'localizableId' })
+ public localizable: Host | Place | Room
}
diff --git a/api/src/app/models/Place.ts b/api/src/app/models/Place.ts
index 1600eb3..0715f2c 100644
--- a/api/src/app/models/Place.ts
+++ b/api/src/app/models/Place.ts
@@ -3,6 +3,7 @@ import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
import ApplicationModel from '@models/ApplicationModel.js'
import Host from '@models/Host.js'
import HostPlace from '@models/HostPlace.js'
+import LocalizedText from '@models/LocalizedText.js'
import Room from '@models/Room.js'

const deco = new Decorators<typeof Place>()
@@ -36,4 +37,12 @@ export default class Place extends ApplicationModel {

@deco.HasMany('Room', { dependent: 'destroy' })
public rooms: Room[]
+
+ @deco.HasMany('LocalizedText', { polymorphic: true, on: 'localizableId', dependent: 'destroy' })
+ public localizedTexts: LocalizedText[]
+
+ @deco.AfterCreate()
+ public async createDefaultLocalizedText(this: Place) {
+ await this.createAssociation('localizedTexts', { locale: 'en-US', title: `My ${this.style}` })
+ }
}
diff --git a/api/src/app/models/Room.ts b/api/src/app/models/Room.ts
index 590f1c2..9e90a30 100644
--- a/api/src/app/models/Room.ts
+++ b/api/src/app/models/Room.ts
@@ -1,6 +1,7 @@
import { Decorators, SoftDelete } from '@rvoh/dream'
import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
import ApplicationModel from '@models/ApplicationModel.js'
+import LocalizedText from '@models/LocalizedText.js'
import Place from '@models/Place.js'

const deco = new Decorators<typeof Room>()
@@ -28,4 +29,12 @@ export default class Room extends ApplicationModel {
@deco.BelongsTo('Place', { on: 'placeId' })
public place: Place
public placeId: DreamColumn<Room, 'placeId'>
+
+ @deco.HasMany('LocalizedText', { polymorphic: true, on: 'localizableId', dependent: 'destroy' })
+ public localizedTexts: LocalizedText[]
+
+ @deco.AfterCreate()
+ public async createDefaultLocalizedText(this: Room) {
+ await this.createAssociation('localizedTexts', { locale: 'en-US' })
+ }
}
diff --git a/api/src/db/migrations/1779137085545-create-localized-text.ts b/api/src/db/migrations/1779137085545-create-localized-text.ts
index 978878c..01402f3 100644
--- a/api/src/db/migrations/1779137085545-create-localized-text.ts
+++ b/api/src/db/migrations/1779137085545-create-localized-text.ts
@@ -2,39 +2,29 @@ import { Kysely, sql } from 'kysely'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function up(db: Kysely<any>): Promise<void> {
- await db.schema
- .createType('localized_types_enum')
- .asEnum([
- 'Host',
- 'Place',
- 'Room'
- ])
- .execute()
+ await db.schema.createType('localized_types_enum').asEnum(['Host', 'Place', 'Room']).execute()

- await db.schema
- .createType('locales_enum')
- .asEnum([
- 'en-US',
- 'es-ES'
- ])
- .execute()
+ await db.schema.createType('locales_enum').asEnum(['en-US', 'es-ES']).execute()

await db.schema
.createTable('localized_texts')
- .addColumn('id', 'uuid', col =>
- col
- .primaryKey()
- .defaultTo(sql`uuidv7()`),
- )
+ .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`uuidv7()`))
.addColumn('localizable_type', sql`localized_types_enum`, col => col.notNull())
.addColumn('localizable_id', 'uuid', col => col.notNull())
.addColumn('locale', sql`locales_enum`, col => col.notNull())
- .addColumn('title', 'varchar(255)', col => col.notNull())
- .addColumn('markdown', 'text', col => col.notNull())
+ .addColumn('title', 'varchar(255)')
+ .addColumn('markdown', 'text')
.addColumn('created_at', 'timestamp', col => col.notNull())
.addColumn('updated_at', 'timestamp', col => col.notNull())
.addColumn('deleted_at', 'timestamp')
.execute()
+
+ await db.schema
+ .createIndex('localized_texts_localizable_for_locale')
+ .on('localized_texts')
+ .columns(['localizable_type', 'localizable_id', 'locale'])
+ .unique()
+ .execute()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -43,4 +33,4 @@ export async function down(db: Kysely<any>): Promise<void> {

await db.schema.dropType('localized_types_enum').execute()
await db.schema.dropType('locales_enum').execute()
-}
\ No newline at end of file
+}
diff --git a/api/src/openapi/mobile.openapi.json b/api/src/openapi/mobile.openapi.json
index 6191a9a..830fd35 100644
--- a/api/src/openapi/mobile.openapi.json
+++ b/api/src/openapi/mobile.openapi.json
@@ -6,6 +6,114 @@
"description": "The autogenerated openapi spec for your app"
},
"paths": {
+ "/v1/host/localized-texts/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "patch": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Update a LocalizedText",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": "string",
+ "enum": [
+ "en-US",
+ "es-ES"
+ ]
+ },
+ "markdown": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Destroy a LocalizedText",
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
"/v1/host/places": {
"parameters": [
{
diff --git a/api/src/openapi/openapi.json b/api/src/openapi/openapi.json
index 8cc03b9..37767da 100644
--- a/api/src/openapi/openapi.json
+++ b/api/src/openapi/openapi.json
@@ -6,6 +6,114 @@
"description": "The autogenerated openapi spec for your app"
},
"paths": {
+ "/v1/host/localized-texts/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "patch": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Update a LocalizedText",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": "string",
+ "enum": [
+ "en-US",
+ "es-ES"
+ ]
+ },
+ "markdown": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Destroy a LocalizedText",
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
"/v1/host/places": {
"parameters": [
{
diff --git a/api/src/openapi/tests.openapi.json b/api/src/openapi/tests.openapi.json
index bd6c5a3..da67510 100644
--- a/api/src/openapi/tests.openapi.json
+++ b/api/src/openapi/tests.openapi.json
@@ -6,6 +6,114 @@
"description": "The autogenerated openapi spec for your app"
},
"paths": {
+ "/v1/host/localized-texts/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "patch": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Update a LocalizedText",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": "string",
+ "enum": [
+ "en-US",
+ "es-ES"
+ ]
+ },
+ "markdown": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "localized-texts"
+ ],
+ "description": "Destroy a LocalizedText",
+ "responses": {
+ "204": {
+ "description": "Success, no content",
+ "$ref": "#/components/responses/NoContent"
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "409": {
+ "$ref": "#/components/responses/Conflict"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationErrors"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
"/v1/host/places": {
"parameters": [
{
diff --git a/api/src/types/db.ts b/api/src/types/db.ts
index e2be8b6..af14200 100644
--- a/api/src/types/db.ts
+++ b/api/src/types/db.ts
@@ -120,6 +120,14 @@ export type Generated<T> =
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>

+export type LocalesEnum = 'en-US' | 'es-ES'
+
+export const LocalesEnumValues = ['en-US', 'es-ES'] as const
+
+export type LocalizedTypesEnum = 'Host' | 'Place' | 'Room'
+
+export const LocalizedTypesEnumValues = ['Host', 'Place', 'Room'] as const
+
export type PlaceStylesEnum =
| 'cabin'
| 'cave'
@@ -181,6 +189,18 @@ export interface Hosts {
userId: string
}

+export interface LocalizedTexts {
+ createdAt: Timestamp
+ deletedAt: Timestamp | null
+ id: Generated<string>
+ locale: LocalesEnum
+ localizableId: string
+ localizableType: LocalizedTypesEnum
+ markdown: string | null
+ title: string | null
+ updatedAt: Timestamp
+}
+
export interface Places {
createdAt: Timestamp
deletedAt: Timestamp | null
@@ -217,6 +237,7 @@ export interface DB {
guests: Guests
host_places: HostPlaces
hosts: Hosts
+ localized_texts: LocalizedTexts
places: Places
rooms: Rooms
users: Users
@@ -226,6 +247,7 @@ export class DBClass {
guests: Guests
host_places: HostPlaces
hosts: Hosts
+ localized_texts: LocalizedTexts
places: Places
rooms: Rooms
users: Users
diff --git a/api/src/types/dream.globals.ts b/api/src/types/dream.globals.ts
index b6e5538..0f826c1 100644
--- a/api/src/types/dream.globals.ts
+++ b/api/src/types/dream.globals.ts
@@ -62,6 +62,8 @@ export const globalTypeConfig = {
'GuestSummarySerializer',
'HostSerializer',
'HostSummarySerializer',
+ 'LocalizedTextSerializer',
+ 'LocalizedTextSummarySerializer',
'PlaceSerializer',
'PlaceSummarySerializer',
'Room/BathroomSerializer',
diff --git a/api/src/types/dream.ts b/api/src/types/dream.ts
index 4d7d356..188991c 100644
--- a/api/src/types/dream.ts
+++ b/api/src/types/dream.ts
@@ -66,11 +66,15 @@ import {
type ApplianceTypesEnum,
type BathOrShowerStylesEnum,
type BedTypesEnum,
+ type LocalesEnum,
+ type LocalizedTypesEnum,
type PlaceStylesEnum,
type RoomTypesEnum,
ApplianceTypesEnumValues,
BathOrShowerStylesEnumValues,
BedTypesEnumValues,
+ LocalesEnumValues,
+ LocalizedTypesEnumValues,
PlaceStylesEnumValues,
RoomTypesEnumValues,
} from './db.js'
@@ -300,6 +304,15 @@ export const schema = {
requiredAndClauses: null,
passthroughAndClauses: null,
},
+ localizedTexts: {
+ type: 'HasMany',
+ foreignKey: 'localizableId',
+ foreignKeyTypeColumn: 'localizableType',
+ tables: ['localized_texts'],
+ optional: null,
+ requiredAndClauses: null,
+ passthroughAndClauses: null,
+ },
places: {
type: 'HasMany',
foreignKey: null,
@@ -320,6 +333,119 @@ export const schema = {
},
},
},
+ localized_texts: {
+ serializerKeys: ['default', 'summary'],
+ scopes: {
+ default: ['dream:SoftDelete'],
+ named: [],
+ },
+ nonJsonColumnNames: [
+ 'createdAt',
+ 'deletedAt',
+ 'id',
+ 'locale',
+ 'localizableId',
+ 'localizableType',
+ 'markdown',
+ 'title',
+ 'updatedAt',
+ ],
+ columns: {
+ createdAt: {
+ coercedType: {} as DateTime,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'timestamp without time zone',
+ allowNull: false,
+ isArray: false,
+ },
+ deletedAt: {
+ coercedType: {} as DateTime | null,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'timestamp without time zone',
+ allowNull: true,
+ isArray: false,
+ },
+ id: {
+ coercedType: {} as string,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'uuid',
+ allowNull: false,
+ isArray: false,
+ },
+ locale: {
+ coercedType: {} as LocalesEnum,
+ enumType: {} as LocalesEnum,
+ enumArrayType: [] as LocalesEnum[],
+ enumValues: LocalesEnumValues,
+ dbType: 'locales_enum',
+ allowNull: false,
+ isArray: false,
+ },
+ localizableId: {
+ coercedType: {} as string,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'uuid',
+ allowNull: false,
+ isArray: false,
+ },
+ localizableType: {
+ coercedType: {} as LocalizedTypesEnum,
+ enumType: {} as LocalizedTypesEnum,
+ enumArrayType: [] as LocalizedTypesEnum[],
+ enumValues: LocalizedTypesEnumValues,
+ dbType: 'localized_types_enum',
+ allowNull: false,
+ isArray: false,
+ },
+ markdown: {
+ coercedType: {} as string | null,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'text',
+ allowNull: true,
+ isArray: false,
+ },
+ title: {
+ coercedType: {} as string | null,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'character varying',
+ allowNull: true,
+ isArray: false,
+ },
+ updatedAt: {
+ coercedType: {} as DateTime,
+ enumType: null,
+ enumArrayType: null,
+ enumValues: null,
+ dbType: 'timestamp without time zone',
+ allowNull: false,
+ isArray: false,
+ },
+ },
+ virtualColumns: [],
+ associations: {
+ localizable: {
+ type: 'BelongsTo',
+ foreignKey: 'localizableId',
+ foreignKeyTypeColumn: 'localizableType',
+ tables: ['hosts', 'places', 'rooms'],
+ optional: false,
+ requiredAndClauses: null,
+ passthroughAndClauses: null,
+ },
+ },
+ },
places: {
serializerKeys: ['default', 'summary'],
scopes: {
@@ -420,6 +546,15 @@ export const schema = {
requiredAndClauses: null,
passthroughAndClauses: null,
},
+ localizedTexts: {
+ type: 'HasMany',
+ foreignKey: 'localizableId',
+ foreignKeyTypeColumn: 'localizableType',
+ tables: ['localized_texts'],
+ optional: null,
+ requiredAndClauses: null,
+ passthroughAndClauses: null,
+ },
rooms: {
type: 'HasMany',
foreignKey: 'placeId',
@@ -543,6 +678,15 @@ export const schema = {
},
virtualColumns: [],
associations: {
+ localizedTexts: {
+ type: 'HasMany',
+ foreignKey: 'localizableId',
+ foreignKeyTypeColumn: 'localizableType',
+ tables: ['localized_texts'],
+ optional: null,
+ requiredAndClauses: null,
+ passthroughAndClauses: null,
+ },
place: {
type: 'BelongsTo',
foreignKey: 'placeId',
@@ -656,6 +800,7 @@ export const connectionTypeConfig = {
Guest: 'guests',
Host: 'hosts',
HostPlace: 'host_places',
+ LocalizedText: 'localized_texts',
Place: 'places',
Room: 'rooms',
'Room/Bathroom': 'rooms',
diff --git a/api/src/types/openapi/tests.openapi.d.ts b/api/src/types/openapi/tests.openapi.d.ts
index 414bbff..dc147fa 100644
--- a/api/src/types/openapi/tests.openapi.d.ts
+++ b/api/src/types/openapi/tests.openapi.d.ts
@@ -1,4 +1,75 @@
export interface paths {
+ "/v1/host/localized-texts/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** @description Destroy a LocalizedText */
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Success, no content */
+ 204: components["responses"]["NoContent"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 422: components["responses"]["ValidationErrors"];
+ 500: components["responses"]["InternalServerError"];
+ };
+ };
+ options?: never;
+ head?: never;
+ /** @description Update a LocalizedText */
+ patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @enum {string} */
+ locale?: "en-US" | "es-ES";
+ markdown?: string | null;
+ title?: string | null;
+ };
+ };
+ };
+ responses: {
+ /** @description Success, no content */
+ 204: components["responses"]["NoContent"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 422: components["responses"]["ValidationErrors"];
+ 500: components["responses"]["InternalServerError"];
+ };
+ };
+ trace?: never;
+ };
"/v1/host/places": {
parameters: {
query?: {