diff --git a/api/spec/factories/LocalizedTextFactory.ts b/api/spec/factories/LocalizedTextFactory.ts
index 0df341b..9a2923b 100644
--- a/api/spec/factories/LocalizedTextFactory.ts
+++ b/api/spec/factories/LocalizedTextFactory.ts
@@ -1,10 +1,11 @@
-import { UpdateableProperties } from '@rvoh/dream/types'
import LocalizedText from '@models/LocalizedText.js'
+import { UpdateableProperties } from '@rvoh/dream/types'
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..2dda12a 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,76 +16,283 @@ 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,
+ context('belonging to a Host', () => {
+ let localizedText: LocalizedText
+
+ beforeEach(async () => {
+ const host = await createHost({ user })
+ localizedText = await host.associationQuery('localizedTexts').firstOrFail()
+ })
+
+ describe('PATCH update', () => {
+ const updateLocalizedText = 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,
+ })
+ }
+
+ it('updates the LocalizedText', async () => {
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText 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')
+ })
+
+ context('a LocalizedText created by another Host', () => {
+ it('is not updated', async () => {
+ const otherHost = await createHost()
+ localizedText = await otherHost.associationQuery('localizedTexts').firstOrFail()
+ const originalLocale = localizedText.locale
+ const originalTitle = localizedText.title
+ const originalMarkdown = localizedText.markdown
+
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText markdown',
+ },
+ 404,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual(originalLocale)
+ expect(localizedText.title).toEqual(originalTitle)
+ expect(localizedText.markdown).toEqual(originalMarkdown)
+ })
+ })
+ })
+
+ describe('DELETE destroy', () => {
+ const destroyLocalizedText = async <StatusCode extends 204 | 400 | 404>(
+ localizedText: LocalizedText,
+ expectedStatus: StatusCode,
+ ) => {
+ return request.delete('/v1/host/localized-texts/{id}', expectedStatus, {
+ id: localizedText.id,
+ })
+ }
+
+ it('deletes the LocalizedText', async () => {
+ await destroyLocalizedText(localizedText, 204)
+
+ expect(await LocalizedText.find(localizedText.id)).toBeNull()
})
- }
- it('updates the LocalizedText', async () => {
- const localizedText = await createLocalizedText({ user })
+ context('a LocalizedText created by another Host', () => {
+ it('is not deleted', async () => {
+ const otherHost = await createHost()
+ localizedText = await otherHost.associationQuery('localizedTexts').firstOrFail()
- await update(localizedText, {
- locale: 'es-ES',
- title: 'Updated LocalizedText title',
- markdown: 'Updated LocalizedText markdown',
- }, 204)
+ await destroyLocalizedText(localizedText, 404)
- await localizedText.reload()
- expect(localizedText.locale).toEqual('es-ES')
- expect(localizedText.title).toEqual('Updated LocalizedText title')
- expect(localizedText.markdown).toEqual('Updated LocalizedText markdown')
+ expect(await LocalizedText.find(localizedText.id)).toMatchDreamModel(localizedText)
+ })
+ })
})
+ })
- context('a LocalizedText created by another User', () => {
- it('is not updated', async () => {
- const localizedText = await createLocalizedText()
- const originalLocale = localizedText.locale
- const originalTitle = localizedText.title
- const originalMarkdown = localizedText.markdown
+ context('belonging to a Place', () => {
+ let localizedText: LocalizedText
- await update(localizedText, {
- locale: 'es-ES',
- title: 'Updated LocalizedText title',
- markdown: 'Updated LocalizedText markdown',
- }, 404)
+ beforeEach(async () => {
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ localizedText = await place.associationQuery('localizedTexts').firstOrFail()
+ })
+
+ describe('PATCH update', () => {
+ const updateLocalizedText = 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,
+ })
+ }
+
+ it('updates the LocalizedText', async () => {
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText markdown',
+ },
+ 204,
+ )
await localizedText.reload()
- expect(localizedText.locale).toEqual(originalLocale)
- expect(localizedText.title).toEqual(originalTitle)
- expect(localizedText.markdown).toEqual(originalMarkdown)
+ expect(localizedText.locale).toEqual('es-ES')
+ expect(localizedText.title).toEqual('Updated LocalizedText title')
+ expect(localizedText.markdown).toEqual('Updated LocalizedText markdown')
+ })
+
+ context('a LocalizedText associated with a Place belonging to a different Host', () => {
+ it('is not updated', async () => {
+ const otherPlace = await createPlace()
+ localizedText = await otherPlace.associationQuery('localizedTexts').firstOrFail()
+ const originalLocale = localizedText.locale
+ const originalTitle = localizedText.title
+ const originalMarkdown = localizedText.markdown
+
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText markdown',
+ },
+ 404,
+ )
+
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual(originalLocale)
+ expect(localizedText.title).toEqual(originalTitle)
+ expect(localizedText.markdown).toEqual(originalMarkdown)
+ })
+ })
+ })
+
+ describe('DELETE destroy', () => {
+ const destroyLocalizedText = async <StatusCode extends 204 | 400 | 404>(
+ localizedText: LocalizedText,
+ expectedStatus: StatusCode,
+ ) => {
+ return request.delete('/v1/host/localized-texts/{id}', expectedStatus, {
+ id: localizedText.id,
+ })
+ }
+
+ it('deletes the LocalizedText', async () => {
+ await destroyLocalizedText(localizedText, 204)
+
+ expect(await LocalizedText.find(localizedText.id)).toBeNull()
+ })
+
+ context('a LocalizedText associated with a Place belonging to a different Host', () => {
+ it('is not deleted', async () => {
+ const otherPlace = await createPlace()
+ localizedText = await otherPlace.associationQuery('localizedTexts').firstOrFail()
+
+ await destroyLocalizedText(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('belonging to a Room', () => {
+ let localizedText: LocalizedText
+
+ beforeEach(async () => {
+ const host = await createHost({ user })
+ const place = await createPlace()
+ await createHostPlace({ host, place })
+ const room = await createDen({ place })
+ localizedText = await room.associationQuery('localizedTexts').firstOrFail()
+ })
+
+ describe('PATCH update', () => {
+ const updateLocalizedText = 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,
+ })
+ }
+
+ it('updates the LocalizedText', async () => {
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText 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')
})
- }
- it('deletes the LocalizedText', async () => {
- const localizedText = await createLocalizedText({ user })
+ context('a LocalizedText associated with a Room belonging to a different Host', () => {
+ it('is not updated', async () => {
+ const otherRoom = await createDen()
+ localizedText = await otherRoom.associationQuery('localizedTexts').firstOrFail()
+ const originalLocale = localizedText.locale
+ const originalTitle = localizedText.title
+ const originalMarkdown = localizedText.markdown
- await destroy(localizedText, 204)
+ await updateLocalizedText(
+ localizedText,
+ {
+ locale: 'es-ES',
+ title: 'Updated LocalizedText title',
+ markdown: 'Updated LocalizedText markdown',
+ },
+ 404,
+ )
- expect(await LocalizedText.find(localizedText.id)).toBeNull()
+ await localizedText.reload()
+ expect(localizedText.locale).toEqual(originalLocale)
+ expect(localizedText.title).toEqual(originalTitle)
+ expect(localizedText.markdown).toEqual(originalMarkdown)
+ })
+ })
})
- context('a LocalizedText created by another User', () => {
- it('is not deleted', async () => {
- const localizedText = await createLocalizedText()
+ describe('DELETE destroy', () => {
+ const destroyLocalizedText = async <StatusCode extends 204 | 400 | 404>(
+ localizedText: LocalizedText,
+ expectedStatus: StatusCode,
+ ) => {
+ return request.delete('/v1/host/localized-texts/{id}', expectedStatus, {
+ id: localizedText.id,
+ })
+ }
+
+ it('deletes the LocalizedText', async () => {
+ await destroyLocalizedText(localizedText, 204)
+
+ expect(await LocalizedText.find(localizedText.id)).toBeNull()
+ })
+
+ context('a LocalizedText associated with a Room belonging to a different Host', () => {
+ it('is not deleted', async () => {
+ const otherRoom = await createDen()
+ localizedText = await otherRoom.associationQuery('localizedTexts').firstOrFail()
- await destroy(localizedText, 404)
+ await destroyLocalizedText(localizedText, 404)
- expect(await LocalizedText.find(localizedText.id)).toMatchDreamModel(localizedText)
+ expect(await LocalizedText.find(localizedText.id)).toMatchDreamModel(localizedText)
+ })
})
})
})
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..3ae3183 100644
--- a/api/spec/unit/models/Room.spec.ts
+++ b/api/spec/unit/models/Room.spec.ts
@@ -1,3 +1,28 @@
+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 () => {
+ // using Den as a stand-in for any Room since STI base models cannot be
+ // saved to the database (enforced by intentionally omitting the base
+ // STI model controller name from the enum values allowed for `type`)
+ 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 () => {
+ // using Den as a stand-in for any Room since STI base models cannot be
+ // saved to the database (enforced by intentionally omitting the base
+ // STI model controller name from the enum values allowed for `type`)
+ const room = await createDen()
+ const localizedText = await room.associationQuery('localizedTexts').firstOrFail()
+
+ expect(localizedText.locale).toEqual('en-US')
+ expect(localizedText.title).toEqual('Den')
+ })
+ })
})
diff --git a/api/src/app/controllers/V1/Host/LocalizedTextsController.ts b/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
index 12f4ab2..1cddd7a 100644
--- a/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
+++ b/api/src/app/controllers/V1/Host/LocalizedTextsController.ts
@@ -1,6 +1,8 @@
+import LocalizedText from '@models/LocalizedText.js'
+import Place from '@models/Place.js'
+import Room from '@models/Room.js'
import { OpenAPI } from '@rvoh/psychic'
import V1HostBaseController from './BaseController.js'
-import LocalizedText from '@models/LocalizedText.js'
const openApiTags = ['localized-texts']
@@ -12,9 +14,9 @@ export default class V1HostLocalizedTextsController extends V1HostBaseController
fastJsonStringify: true,
})
public async update() {
- // const localizedText = await this.localizedText()
- // await localizedText.update(this.paramsFor(LocalizedText))
- // this.noContent()
+ const localizedText = await this.localizedText()
+ await localizedText.update(this.paramsFor(LocalizedText))
+ this.noContent()
}
@OpenAPI({
@@ -24,14 +26,53 @@ 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 localizedText = await LocalizedText.preload('localizable').findOrFail(
+ this.castParam('id', 'string'),
+ )
+
+ const localizable = localizedText.localizable
+
+ switch (localizedText.localizableType) {
+ case 'Host':
+ if (!localizable.equals(this.currentHost)) this.notFound()
+ return localizedText
+
+ case 'Place':
+ // the next line safely informs typescript that localizable is a Place
+ if (!(localizable instanceof Place)) throw new Error('unreachable')
+
+ if (!(await localizable.associationQuery('hosts', { and: { id: this.currentHost.id } }).exists()))
+ this.notFound()
+
+ return localizedText
+
+ case 'Room':
+ // the next line safely informs typescript that localizable is a Room
+ // make sure this imports from `import Room from '@models/Room.js'`
+ // not from `import { Room } from 'socket.io-adapter'`
+ if (!(localizable instanceof Room)) throw new Error('unreachable')
+
+ if (
+ !(await localizable
+ .query()
+ .innerJoin('place', 'hosts', { and: { id: this.currentHost.id } })
+ .exists())
+ )
+ this.notFound()
+
+ return localizedText
+
+ default:
+ this.notFound()
+ // notFound already throws an exception, but Typescript doesn't know that; the following
+ // line informs typescript that this method always returns a LocalizableText
+ throw new Error('unreachable')
+ }
}
}
diff --git a/api/src/app/models/Host.ts b/api/src/app/models/Host.ts
index b5e8782..4fae757 100644
--- a/api/src/app/models/Host.ts
+++ b/api/src/app/models/Host.ts
@@ -1,5 +1,6 @@
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'
import { Decorators } from '@rvoh/dream'
@@ -32,4 +33,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 d73684f..b44ef83 100644
--- a/api/src/app/models/LocalizedText.ts
+++ b/api/src/app/models/LocalizedText.ts
@@ -1,6 +1,9 @@
+import ApplicationModel from '@models/ApplicationModel.js'
+import Host from '@models/Host.js'
+import Place from '@models/Place.js'
+import Room from '@models/Room.js'
import { Decorators } from '@rvoh/dream'
import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
-import ApplicationModel from '@models/ApplicationModel.js'
const deco = new Decorators<typeof LocalizedText>()
@@ -25,4 +28,9 @@ export default class LocalizedText extends ApplicationModel {
public deletedAt: DreamColumn<LocalizedText, 'deletedAt'>
public createdAt: DreamColumn<LocalizedText, 'createdAt'>
public updatedAt: DreamColumn<LocalizedText, 'updatedAt'>
+
+ @deco.BelongsTo(['Host', 'Place', 'Room'], { polymorphic: true, on: 'localizableId' })
+ // make sure this imports from `import Room from '@models/Room.js'`
+ // not from `import { Room } from 'socket.io-adapter'`
+ public localizable: Host | Place | Room
}
diff --git a/api/src/app/models/Place.ts b/api/src/app/models/Place.ts
index 9243aa1..717990e 100644
--- a/api/src/app/models/Place.ts
+++ b/api/src/app/models/Place.ts
@@ -1,6 +1,7 @@
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'
import { Decorators } from '@rvoh/dream'
import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
@@ -37,4 +38,12 @@ export default class Place extends ApplicationModel {
// make sure this imports from `import Room from '@models/Room.js'`
// not from `import { Room } from 'socket.io-adapter'`
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 7b34e52..fc0ed30 100644
--- a/api/src/app/models/Room.ts
+++ b/api/src/app/models/Room.ts
@@ -1,4 +1,5 @@
import ApplicationModel from '@models/ApplicationModel.js'
+import LocalizedText from '@models/LocalizedText.js'
import Place from '@models/Place.js'
import { Decorators } from '@rvoh/dream'
import { DreamColumn } from '@rvoh/dream/types'
@@ -20,4 +21,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', title: this.type })
+ }
}
diff --git a/api/src/db/migrations/1773154136747-create-localized-text.ts b/api/src/db/migrations/1773154136747-create-localized-text.ts
index 58549f0..5819bd4 100644
--- a/api/src/db/migrations/1773154136747-create-localized-text.ts
+++ b/api/src/db/migrations/1773154136747-create-localized-text.ts
@@ -29,12 +29,19 @@ 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('deleted_at', 'timestamp')
.addColumn('created_at', 'timestamp', col => col.notNull())
.addColumn('updated_at', 'timestamp', col => col.notNull())
.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
diff --git a/api/src/openapi/mobile.openapi.json b/api/src/openapi/mobile.openapi.json
index a5705ec..f4e9077 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 e4a969f..10bf720 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 fcc24fe..8d6ad26 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 cacb7e2..0fd2b2c 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'
@@ -179,6 +187,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
@@ -214,6 +234,7 @@ export interface DB {
guests: Guests
host_places: HostPlaces
hosts: Hosts
+ localized_texts: LocalizedTexts
places: Places
rooms: Rooms
users: Users
@@ -223,6 +244,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 9e6360b..73f17b4 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'
@@ -282,6 +286,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,
@@ -302,6 +315,119 @@ export const schema = {
},
},
},
+ localized_texts: {
+ serializerKeys: ['default', 'summary'],
+ scopes: {
+ default: [],
+ 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: {
@@ -402,6 +528,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',
@@ -525,6 +660,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',
@@ -628,6 +772,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 8135272..b2bfc20 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?: {