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
```

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqRHFQRjCHrGh9xPhFnGUR

Changes​

diff --git a/api/spec/factories/LocalizedTextFactory.ts b/api/spec/factories/LocalizedTextFactory.ts
index 7847e79..7273108 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 ?? (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 301c8e5..3120c0a 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 73ac7a6..6648798 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 db90ace..6aa9bb6 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..021a823 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 222ee8f..9909bac 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', 'uuid'))
+ 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 2efa953..5cb2091 100644
--- a/api/src/app/models/Host.ts
+++ b/api/src/app/models/Host.ts
@@ -1,7 +1,8 @@
-import { Decorators, SoftDelete } from '@rvoh/dream'
+import { Decorators, Dream, DreamTransaction, 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, txn: DreamTransaction<Dream> | null) {
+ await this.txn(txn).createAssociation('localizedTexts', { locale: 'en-US' })
+ }
}
diff --git a/api/src/app/models/LocalizedText.ts b/api/src/app/models/LocalizedText.ts
index e9c9ef8..98760af 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 5f6d8be..20b369a 100644
--- a/api/src/app/models/Place.ts
+++ b/api/src/app/models/Place.ts
@@ -1,8 +1,9 @@
-import { Decorators, SoftDelete } from '@rvoh/dream'
+import { Decorators, Dream, DreamTransaction, SoftDelete } from '@rvoh/dream'
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, txn: DreamTransaction<Dream> | null) {
+ await this.txn(txn).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 8fd3fc7..b8565d9 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 { Decorators, Dream, DreamTransaction, 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, txn: DreamTransaction<Dream> | null) {
+ await this.txn(txn).createAssociation('localizedTexts', { locale: 'en-US' })
+ }
}
diff --git a/api/src/db/migrations/1793240746772-create-localized-text.ts b/api/src/db/migrations/1793240746772-create-localized-text.ts
index 978878c..87d399e 100644
--- a/api/src/db/migrations/1793240746772-create-localized-text.ts
+++ b/api/src/db/migrations/1793240746772-create-localized-text.ts
@@ -1,4 +1,4 @@
-import { Kysely, sql } from 'kysely'
+import { Kysely, sql, type SqlBool } from 'kysely'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function up(db: Kysely<any>): Promise<void> {
@@ -29,12 +29,20 @@ export async function up(db: Kysely<any>): Promise<void> {
.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()
+ .where(sql<SqlBool>`deleted_at IS NULL`)
+ .execute()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/api/src/openapi/mobile.openapi.json b/api/src/openapi/mobile.openapi.json
index a0ec6d8..7ebe66d 100644
--- a/api/src/openapi/mobile.openapi.json
+++ b/api/src/openapi/mobile.openapi.json
@@ -6,6 +6,108 @@
"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"
+ },
+ "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"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
"/v1/host/places": {
"parameters": [
{
diff --git a/api/src/openapi/openapi.json b/api/src/openapi/openapi.json
index e245d0e..9bfe022 100644
--- a/api/src/openapi/openapi.json
+++ b/api/src/openapi/openapi.json
@@ -6,6 +6,108 @@
"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"
+ },
+ "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"
+ },
+ "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 8d80904..e57825d 100644
--- a/api/src/openapi/tests.openapi.json
+++ b/api/src/openapi/tests.openapi.json
@@ -6,6 +6,108 @@
"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"
+ },
+ "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"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
"/v1/host/places": {
"parameters": [
{
diff --git a/api/src/types/db.ts b/api/src/types/db.ts
index 8d36004..4f5facc 100644
--- a/api/src/types/db.ts
+++ b/api/src/types/db.ts
@@ -108,6 +108,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' | 'cottage' | 'dump' | 'lean_to' | 'tent' | 'treehouse'

@@ -159,6 +167,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
@@ -195,6 +215,7 @@ export interface DB {
guests: Guests
host_places: HostPlaces
hosts: Hosts
+ localized_texts: LocalizedTexts
places: Places
rooms: Rooms
users: Users
@@ -204,6 +225,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 83ea146..5447e50 100644
--- a/api/src/types/openapi/tests.openapi.d.ts
+++ b/api/src/types/openapi/tests.openapi.d.ts
@@ -1,4 +1,73 @@
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"];
+ 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"];
+ 500: components["responses"]["InternalServerError"];
+ };
+ };
+ trace?: never;
+ };
"/v1/host/places": {
parameters: {
query?: {