Skip to main content

Places controller for Visitors, including localization

Commit Message​

Places controller for Visitors, including localization

If the i18n text is coming through in English when it should
be Spanish, make sure you have added `es` to the locales map
exported by api/src/conf/locales/index.ts and that you have
updated `supportedLocales`, exported by api/src/utils/i18n.ts,
to `LocalesEnumValues` (which ties back to the database level
enum of our supported locales).

If you get ambiguous Typescript errors along the way, restart
the ESLint server in your editor, and if that does not work,
quit and re-open your editor

The Visitor namespace uses MaybeAuthedController as its base,
so the Places index and show endpoints work whether or not the
caller is authenticated. The spec demonstrates both contexts.

```console
pnpm psy g:controller --help
pnpm psy g:controller Visitor/V1/Places index show

pnpm psy sync

pnpm build
pnpm build:spec

pnpm uspec spec/unit/controllers/Visitor/V1/PlacesController.spec.ts
// If you get 400, use NODE_DEBUG=psychic to see OpenAPI validation errors
NODE_DEBUG=psychic pnpm uspec spec/unit/controllers/Visitor/V1/PlacesController.spec.ts
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/unit/controllers/Visitor/V1/PlacesController.spec.ts b/api/spec/unit/controllers/Visitor/V1/PlacesController.spec.ts
new file mode 100644
index 0000000..03ded27
--- /dev/null
+++ b/api/spec/unit/controllers/Visitor/V1/PlacesController.spec.ts
@@ -0,0 +1,223 @@
+import Place from '#models/Place.js'
+import User from '#models/User.js'
+import { PsychicServer } from '@rvoh/psychic'
+import { OpenapiSpecRequest } from '@rvoh/psychic-spec-helpers'
+import createLocalizedText from '#spec/factories/LocalizedTextFactory.js'
+import createPlace from '#spec/factories/PlaceFactory.js'
+import createBathroom from '#spec/factories/Room/BathroomFactory.js'
+import createBedroom from '#spec/factories/Room/BedroomFactory.js'
+import createDen from '#spec/factories/Room/DenFactory.js'
+import createKitchen from '#spec/factories/Room/KitchenFactory.js'
+import createLivingRoom from '#spec/factories/Room/LivingRoomFactory.js'
+import createUser from '#spec/factories/UserFactory.js'
+import { session, SpecRequestType } from '#spec/unit/helpers/authentication.js'
+import { paths as OpenapiPaths } from '#src/types/openapi/tests.openapi.js'
+
+describe('Visitor/V1/PlacesController', () => {
+ describe('authenticated', () => {
+ let request: SpecRequestType
+ let user: User
+
+ beforeEach(async () => {
+ user = await createUser()
+ request = await session(user)
+ })
+
+ describe('GET index', () => {
+ const subject = async <StatusCode extends 200 | 400>(expectedStatus: StatusCode) => {
+ return request.get('/v1/places', expectedStatus, {
+ headers: {
+ 'accept-language': 'es-ES',
+ },
+ })
+ }
+
+ it('returns the index of Places', async () => {
+ const place = await createPlace()
+ await createLocalizedText({ localizable: place, locale: 'es-ES', title: 'The Spanish title' })
+
+ const { body } = await subject(200)
+
+ expect(body.results).toEqual([
+ {
+ id: place.id,
+ title: 'The Spanish title',
+ },
+ ])
+ })
+ })
+
+ describe('GET show', () => {
+ const subject = async <StatusCode extends 200 | 400>(place: Place, expectedStatus: StatusCode) => {
+ return request.get('/v1/places/{id}', expectedStatus, {
+ id: place.id,
+ headers: {
+ 'accept-language': 'es-ES',
+ },
+ })
+ }
+
+ it('returns places with rooms', async () => {
+ const place = await createPlace({ style: 'cabin', sleeps: 3 })
+ await createLocalizedText({ localizable: place, locale: 'es-ES', title: 'The Spanish place title' })
+
+ const { kitchen, bathroom, bedroom, den, livingRoom } = await createRoomsForPlace(place)
+
+ const { body } = await subject(place, 200)
+
+ expect(body).toEqual(expectedShowBody({ place, kitchen, bathroom, bedroom, den, livingRoom }))
+ })
+ })
+ })
+
+ describe('unauthenticated', () => {
+ let request: OpenapiSpecRequest<OpenapiPaths>
+
+ beforeEach(async () => {
+ request = new OpenapiSpecRequest<OpenapiPaths>()
+ await request.init(PsychicServer)
+ })
+
+ describe('GET index', () => {
+ const subject = async <StatusCode extends 200 | 400>(expectedStatus: StatusCode) => {
+ return request.get('/v1/places', expectedStatus, {
+ headers: {
+ 'accept-language': 'es-ES',
+ },
+ })
+ }
+
+ it('returns the index of Places', async () => {
+ const place = await createPlace()
+ await createLocalizedText({ localizable: place, locale: 'es-ES', title: 'The Spanish title' })
+
+ const { body } = await subject(200)
+
+ expect(body.results).toEqual([
+ {
+ id: place.id,
+ title: 'The Spanish title',
+ },
+ ])
+ })
+ })
+
+ describe('GET show', () => {
+ const subject = async <StatusCode extends 200 | 400>(place: Place, expectedStatus: StatusCode) => {
+ return request.get('/v1/places/{id}', expectedStatus, {
+ id: place.id,
+ headers: {
+ 'accept-language': 'es-ES',
+ },
+ })
+ }
+
+ it('returns places with rooms', async () => {
+ const place = await createPlace({ style: 'cabin', sleeps: 3 })
+ await createLocalizedText({ localizable: place, locale: 'es-ES', title: 'The Spanish place title' })
+
+ const { kitchen, bathroom, bedroom, den, livingRoom } = await createRoomsForPlace(place)
+
+ const { body } = await subject(place, 200)
+
+ expect(body).toEqual(expectedShowBody({ place, kitchen, bathroom, bedroom, den, livingRoom }))
+ })
+ })
+ })
+})
+
+async function createRoomsForPlace(place: Place) {
+ const kitchen = await createKitchen({ place, appliances: ['oven', 'dishwasher'] })
+ await createLocalizedText({ localizable: kitchen, locale: 'es-ES', title: 'The Spanish kitchen title' })
+
+ const bathroom = await createBathroom({ place, bathOrShowerStyle: 'shower' })
+ await createLocalizedText({
+ localizable: bathroom,
+ locale: 'es-ES',
+ title: 'The Spanish bathroom title',
+ })
+
+ const bedroom = await createBedroom({ place, bedTypes: ['cot', 'bunk'] })
+ await createLocalizedText({ localizable: bedroom, locale: 'es-ES', title: 'The Spanish bedroom title' })
+
+ const den = await createDen({ place })
+ await createLocalizedText({ localizable: den, locale: 'es-ES', title: 'The Spanish den title' })
+
+ const livingRoom = await createLivingRoom({ place })
+ await createLocalizedText({
+ localizable: livingRoom,
+ locale: 'es-ES',
+ title: 'The Spanish livingRoom title',
+ })
+
+ return { kitchen, bathroom, bedroom, den, livingRoom }
+}
+
+function expectedShowBody({
+ place,
+ kitchen,
+ bathroom,
+ bedroom,
+ den,
+ livingRoom,
+}: Awaited<ReturnType<typeof createRoomsForPlace>> & { place: Place }) {
+ return {
+ id: place.id,
+ sleeps: 3,
+ title: 'The Spanish place title',
+ style: 'cabin',
+ displayStyle: 'cabaña rústica',
+
+ rooms: [
+ {
+ id: kitchen.id,
+ type: 'Kitchen',
+ displayType: 'cocina',
+ title: 'The Spanish kitchen title',
+ appliances: [
+ {
+ value: 'oven',
+ label: 'horno',
+ },
+ {
+ value: 'dishwasher',
+ label: 'lavavajillas',
+ },
+ ],
+ },
+ {
+ id: bathroom.id,
+ type: 'Bathroom',
+ displayType: 'baño',
+ title: 'The Spanish bathroom title',
+ bathOrShowerStyle: {
+ value: 'shower',
+ label: 'ducha',
+ },
+ },
+ {
+ id: bedroom.id,
+ type: 'Bedroom',
+ displayType: 'dormitorio',
+ title: 'The Spanish bedroom title',
+ bedTypes: [
+ {
+ value: 'cot',
+ label: 'catre',
+ },
+ {
+ value: 'bunk',
+ label: 'litera',
+ },
+ ],
+ },
+ { id: den.id, type: 'Den', displayType: 'estudio', title: 'The Spanish den title' },
+ {
+ id: livingRoom.id,
+ type: 'LivingRoom',
+ displayType: 'sala de estar',
+ title: 'The Spanish livingRoom title',
+ },
+ ],
+ }
+}
diff --git a/api/src/app/controllers/ApplicationController.ts b/api/src/app/controllers/ApplicationController.ts
index eed6c22..108cc59 100644
--- a/api/src/app/controllers/ApplicationController.ts
+++ b/api/src/app/controllers/ApplicationController.ts
@@ -1,4 +1,4 @@
-import { PsychicController } from '@rvoh/psychic'
+import { BeforeAction, PsychicController } from '@rvoh/psychic'
import { PsychicOpenapiNames } from '@rvoh/psychic/openapi'
import psychicTypes from '#src/types/psychic.js'
import { DEFAULT_LOCALE, supportedLocales } from '#src/utils/i18n.js'
@@ -12,6 +12,13 @@ export default class ApplicationController extends PsychicController {
return ['default', 'mobile', 'tests']
}

+ @BeforeAction()
+ public configureSerializers() {
+ this.serializerPassthrough({
+ locale: this.locale,
+ })
+ }
+
// The supported locale (src/utils/i18n.ts) the request's Accept-Language
// header prefers, else DEFAULT_LOCALE. Koa does the negotiation (its accepts
// and negotiator packages), weighing q-values and matching a bare 'es' to
diff --git a/api/src/app/controllers/MaybeAuthedController.ts b/api/src/app/controllers/MaybeAuthedController.ts
index df96b74..b95e475 100644
--- a/api/src/app/controllers/MaybeAuthedController.ts
+++ b/api/src/app/controllers/MaybeAuthedController.ts
@@ -1,16 +1,13 @@
import ApplicationController from '#controllers/ApplicationController.js'
+import resolveCurrentUser from '#controllers/helpers/resolveCurrentUser.js'
+import User from '#models/User.js'
import { BeforeAction } from '@rvoh/psychic'
-/** uncomment after creating User model */
-// import resolveCurrentUser from '#controllers/helpers/resolveCurrentUser.js'
-// import User from '#models/User.js'

export default class MaybeAuthedController extends ApplicationController {
- /** uncomment after creating User model */
- // protected currentUser: User | null = null
+ protected currentUser: User | null = null

@BeforeAction()
protected async authenticate() {
- /** uncomment after creating User model */
- // this.currentUser = await resolveCurrentUser(this)
+ this.currentUser = await resolveCurrentUser(this)
}
}
diff --git a/api/src/app/controllers/V1/BaseController.ts b/api/src/app/controllers/V1/BaseController.ts
index a30ab80..6f2881c 100644
--- a/api/src/app/controllers/V1/BaseController.ts
+++ b/api/src/app/controllers/V1/BaseController.ts
@@ -1,5 +1,3 @@
import AuthedController from '../AuthedController.js'

-export default class V1BaseController extends AuthedController {
-
-}
+export default class V1BaseController extends AuthedController {}
diff --git a/api/src/app/controllers/Visitor/BaseController.ts b/api/src/app/controllers/Visitor/BaseController.ts
new file mode 100644
index 0000000..ee11816
--- /dev/null
+++ b/api/src/app/controllers/Visitor/BaseController.ts
@@ -0,0 +1,3 @@
+import MaybeAuthedController from '../MaybeAuthedController.js'
+
+export default class VisitorBaseController extends MaybeAuthedController {}
diff --git a/api/src/app/controllers/Visitor/V1/BaseController.ts b/api/src/app/controllers/Visitor/V1/BaseController.ts
new file mode 100644
index 0000000..e07512e
--- /dev/null
+++ b/api/src/app/controllers/Visitor/V1/BaseController.ts
@@ -0,0 +1,3 @@
+import VisitorBaseController from '../BaseController.js'
+
+export default class VisitorV1BaseController extends VisitorBaseController {}
diff --git a/api/src/app/controllers/Visitor/V1/PlacesController.ts b/api/src/app/controllers/Visitor/V1/PlacesController.ts
new file mode 100644
index 0000000..7c32c80
--- /dev/null
+++ b/api/src/app/controllers/Visitor/V1/PlacesController.ts
@@ -0,0 +1,37 @@
+import Place from '#models/Place.js'
+import { OpenAPI } from '@rvoh/psychic'
+import VisitorV1BaseController from './BaseController.js'
+
+const openApiTags = ['visitor-places']
+
+export default class VisitorV1PlacesController extends VisitorV1BaseController {
+ @OpenAPI(Place, {
+ status: 200,
+ tags: openApiTags,
+ description: 'Place index endpoint for Visitors',
+ cursorPaginate: true,
+ serializerKey: 'summaryForVisitors',
+ fastJsonStringify: true,
+ })
+ public async index() {
+ const places = await Place.passthrough({ locale: this.locale })
+ .preloadFor('summaryForVisitors')
+ .cursorPaginate({ cursor: this.castParam('cursor', 'string', { allowNull: true }) })
+ this.ok(places)
+ }
+
+ @OpenAPI(Place, {
+ status: 200,
+ tags: openApiTags,
+ description: 'Place show endpoint for Visitors',
+ serializerKey: 'forVisitors',
+ fastJsonStringify: true,
+ })
+ public async show() {
+ this.ok(
+ await Place.passthrough({ locale: this.locale })
+ .preloadFor('forVisitors')
+ .findOrFail(this.castParam('id', 'uuid')),
+ )
+ }
+}
diff --git a/api/src/app/models/Place.ts b/api/src/app/models/Place.ts
index c063540..3ed5fa2 100644
--- a/api/src/app/models/Place.ts
+++ b/api/src/app/models/Place.ts
@@ -18,6 +18,8 @@ export default class Place extends ApplicationModel {
return {
default: 'PlaceSerializer',
summary: 'PlaceSummarySerializer',
+ summaryForVisitors: 'PlaceSummaryForVisitorsSerializer',
+ forVisitors: 'PlaceForVisitorsSerializer',
}
}

diff --git a/api/src/app/models/Room/Bathroom.ts b/api/src/app/models/Room/Bathroom.ts
index 5bcc095..9aa3327 100644
--- a/api/src/app/models/Room/Bathroom.ts
+++ b/api/src/app/models/Room/Bathroom.ts
@@ -8,6 +8,7 @@ export default class Bathroom extends Room {
return {
default: 'Room/BathroomSerializer',
summary: 'Room/BathroomSummarySerializer',
+ forVisitors: 'Room/BathroomForVisitorsSerializer',
}
}

diff --git a/api/src/app/models/Room/Bedroom.ts b/api/src/app/models/Room/Bedroom.ts
index 3a3909d..f18fd63 100644
--- a/api/src/app/models/Room/Bedroom.ts
+++ b/api/src/app/models/Room/Bedroom.ts
@@ -8,6 +8,7 @@ export default class Bedroom extends Room {
return {
default: 'Room/BedroomSerializer',
summary: 'Room/BedroomSummarySerializer',
+ forVisitors: 'Room/BedroomForVisitorsSerializer',
}
}

diff --git a/api/src/app/models/Room/Den.ts b/api/src/app/models/Room/Den.ts
index 6cac44b..dac86a9 100644
--- a/api/src/app/models/Room/Den.ts
+++ b/api/src/app/models/Room/Den.ts
@@ -1,5 +1,5 @@
import { STI } from '@rvoh/dream'
-import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
+import { DreamSerializers } from '@rvoh/dream/types'
import Room from '#models/Room.js'

@STI(Room)
@@ -8,7 +8,7 @@ export default class Den extends Room {
return {
default: 'Room/DenSerializer',
summary: 'Room/DenSummarySerializer',
+ forVisitors: 'Room/DenForVisitorsSerializer',
}
}
-
}
diff --git a/api/src/app/models/Room/Kitchen.ts b/api/src/app/models/Room/Kitchen.ts
index b94f747..0469a38 100644
--- a/api/src/app/models/Room/Kitchen.ts
+++ b/api/src/app/models/Room/Kitchen.ts
@@ -8,6 +8,7 @@ export default class Kitchen extends Room {
return {
default: 'Room/KitchenSerializer',
summary: 'Room/KitchenSummarySerializer',
+ forVisitors: 'Room/KitchenForVisitorsSerializer',
}
}

diff --git a/api/src/app/models/Room/LivingRoom.ts b/api/src/app/models/Room/LivingRoom.ts
index caba904..3b1db04 100644
--- a/api/src/app/models/Room/LivingRoom.ts
+++ b/api/src/app/models/Room/LivingRoom.ts
@@ -1,5 +1,5 @@
import { STI } from '@rvoh/dream'
-import { DreamColumn, DreamSerializers } from '@rvoh/dream/types'
+import { DreamSerializers } from '@rvoh/dream/types'
import Room from '#models/Room.js'

@STI(Room)
@@ -8,7 +8,7 @@ export default class LivingRoom extends Room {
return {
default: 'Room/LivingRoomSerializer',
summary: 'Room/LivingRoomSummarySerializer',
+ forVisitors: 'Room/LivingRoomForVisitorsSerializer',
}
}
-
}
diff --git a/api/src/app/serializers/PlaceSerializer.ts b/api/src/app/serializers/PlaceSerializer.ts
index a4a00c3..1014927 100644
--- a/api/src/app/serializers/PlaceSerializer.ts
+++ b/api/src/app/serializers/PlaceSerializer.ts
@@ -1,5 +1,7 @@
import { DreamSerializer } from '@rvoh/dream'
import Place from '#models/Place.js'
+import { type LocalesEnum } from '#src/types/db.js'
+import i18n from '#src/utils/i18n.js'

export const PlaceSummarySerializer = (place: Place) =>
DreamSerializer(Place, place)
@@ -10,3 +12,17 @@ export const PlaceSerializer = (place: Place) =>
PlaceSummarySerializer(place)
.attribute('style')
.attribute('sleeps')
+
+export const PlaceSummaryForVisitorsSerializer = (place: Place) =>
+ DreamSerializer(Place, place)
+ .attribute('id')
+ .delegatedAttribute('currentLocalizedText', 'title')
+
+export const PlaceForVisitorsSerializer = (place: Place, passthrough: { locale: LocalesEnum }) =>
+ PlaceSummaryForVisitorsSerializer(place)
+ .attribute('style')
+ .customAttribute('displayStyle', () => i18n(passthrough.locale, `places.style.${place.style}`), {
+ openapi: 'string',
+ })
+ .attribute('sleeps')
+ .rendersMany('rooms', { serializerKey: 'forVisitors' })
diff --git a/api/src/app/serializers/Room/BathroomSerializer.ts b/api/src/app/serializers/Room/BathroomSerializer.ts
index 9e8de81..8784719 100644
--- a/api/src/app/serializers/Room/BathroomSerializer.ts
+++ b/api/src/app/serializers/Room/BathroomSerializer.ts
@@ -1,5 +1,12 @@
-import { RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
+import { ObjectSerializer } from '@rvoh/dream'
+import { RoomForVisitorsSerializer, RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
import Bathroom from '#models/Room/Bathroom.js'
+import {
+ type BathOrShowerStylesEnum,
+ BathOrShowerStylesEnumValues,
+ type LocalesEnum,
+} from '#src/types/db.js'
+import i18n from '#src/utils/i18n.js'

export const RoomBathroomSummarySerializer = (bathroom: Bathroom) =>
RoomSummarySerializer(Bathroom, bathroom)
@@ -7,3 +14,24 @@ export const RoomBathroomSummarySerializer = (bathroom: Bathroom) =>
export const RoomBathroomSerializer = (bathroom: Bathroom) =>
RoomSerializer(Bathroom, bathroom)
.attribute('bathOrShowerStyle')
+
+export const BathOrShowerStyleSerializer = (
+ bathOrShowerStyle: BathOrShowerStylesEnum,
+ passthrough: { locale: LocalesEnum }
+) =>
+ ObjectSerializer({ bathOrShowerStyle }, passthrough)
+ .attribute('bathOrShowerStyle', {
+ as: 'value',
+ openapi: { type: 'string', enum: BathOrShowerStylesEnumValues },
+ })
+ .customAttribute(
+ 'label',
+ () => i18n(passthrough.locale, `rooms.Bathroom.bathOrShowerStyles.${bathOrShowerStyle}`),
+ {
+ openapi: 'string',
+ }
+ )
+
+export const RoomBathroomForVisitorsSerializer = (roomBathroom: Bathroom, passthrough: { locale: LocalesEnum }) =>
+ RoomForVisitorsSerializer(Bathroom, roomBathroom, passthrough)
+ .rendersOne<Bathroom>('bathOrShowerStyle', { serializer: BathOrShowerStyleSerializer })
diff --git a/api/src/app/serializers/Room/BedroomSerializer.ts b/api/src/app/serializers/Room/BedroomSerializer.ts
index a758a17..ddfebf7 100644
--- a/api/src/app/serializers/Room/BedroomSerializer.ts
+++ b/api/src/app/serializers/Room/BedroomSerializer.ts
@@ -1,5 +1,8 @@
-import { RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
+import { ObjectSerializer } from '@rvoh/dream'
+import { RoomForVisitorsSerializer, RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
import Bedroom from '#models/Room/Bedroom.js'
+import { type BedTypesEnum, BedTypesEnumValues, type LocalesEnum } from '#src/types/db.js'
+import i18n from '#src/utils/i18n.js'

export const RoomBedroomSummarySerializer = (bedroom: Bedroom) =>
RoomSummarySerializer(Bedroom, bedroom)
@@ -7,3 +10,15 @@ export const RoomBedroomSummarySerializer = (bedroom: Bedroom) =>
export const RoomBedroomSerializer = (bedroom: Bedroom) =>
RoomSerializer(Bedroom, bedroom)
.attribute('bedTypes')
+
+export const BedTypeSerializer = (bedType: BedTypesEnum, passthrough: { locale: LocalesEnum }) =>
+ ObjectSerializer({ bedType }, passthrough)
+ .attribute('bedType', { as: 'value', openapi: { type: 'string', enum: BedTypesEnumValues } })
+ .customAttribute('label', () => i18n(passthrough.locale, `rooms.Bedroom.bedTypes.${bedType}`), {
+ openapi: 'string',
+ })
+
+export const RoomBedroomForVisitorsSerializer = (roomBedroom: Bedroom, passthrough: { locale: LocalesEnum }) =>
+ RoomForVisitorsSerializer(Bedroom, roomBedroom, passthrough).rendersMany<Bedroom>('bedTypes', {
+ serializer: BedTypeSerializer,
+ })
diff --git a/api/src/app/serializers/Room/DenSerializer.ts b/api/src/app/serializers/Room/DenSerializer.ts
index cf1dff8..6ad7bf7 100644
--- a/api/src/app/serializers/Room/DenSerializer.ts
+++ b/api/src/app/serializers/Room/DenSerializer.ts
@@ -1,8 +1,12 @@
-import { RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
+import { RoomForVisitorsSerializer, RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
import Den from '#models/Room/Den.js'
+import { type LocalesEnum } from '#src/types/db.js'

export const RoomDenSummarySerializer = (den: Den) =>
RoomSummarySerializer(Den, den)

export const RoomDenSerializer = (den: Den) =>
RoomSerializer(Den, den)
+
+export const RoomDenForVisitorsSerializer = (roomDen: Den, passthrough: { locale: LocalesEnum }) =>
+ RoomForVisitorsSerializer(Den, roomDen, passthrough)
diff --git a/api/src/app/serializers/Room/KitchenSerializer.ts b/api/src/app/serializers/Room/KitchenSerializer.ts
index 7af4b41..bcdb4a4 100644
--- a/api/src/app/serializers/Room/KitchenSerializer.ts
+++ b/api/src/app/serializers/Room/KitchenSerializer.ts
@@ -1,5 +1,8 @@
-import { RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
+import { ObjectSerializer } from '@rvoh/dream'
+import { RoomForVisitorsSerializer, RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
import Kitchen from '#models/Room/Kitchen.js'
+import { type ApplianceTypesEnum, ApplianceTypesEnumValues, type LocalesEnum } from '#src/types/db.js'
+import i18n from '#src/utils/i18n.js'

export const RoomKitchenSummarySerializer = (kitchen: Kitchen) =>
RoomSummarySerializer(Kitchen, kitchen)
@@ -7,3 +10,15 @@ export const RoomKitchenSummarySerializer = (kitchen: Kitchen) =>
export const RoomKitchenSerializer = (kitchen: Kitchen) =>
RoomSerializer(Kitchen, kitchen)
.attribute('appliances')
+
+export const ApplianceSerializer = (appliance: ApplianceTypesEnum, passthrough: { locale: LocalesEnum }) =>
+ ObjectSerializer({ appliance }, passthrough)
+ .attribute('appliance', { as: 'value', openapi: { type: 'string', enum: ApplianceTypesEnumValues } })
+ .customAttribute('label', () => i18n(passthrough.locale, `rooms.Kitchen.appliances.${appliance}`), {
+ openapi: 'string',
+ })
+
+export const RoomKitchenForVisitorsSerializer = (roomKitchen: Kitchen, passthrough: { locale: LocalesEnum }) =>
+ RoomForVisitorsSerializer(Kitchen, roomKitchen, passthrough).rendersMany<Kitchen>('appliances', {
+ serializer: ApplianceSerializer,
+ })
diff --git a/api/src/app/serializers/Room/LivingRoomSerializer.ts b/api/src/app/serializers/Room/LivingRoomSerializer.ts
index 957b7d1..4be600d 100644
--- a/api/src/app/serializers/Room/LivingRoomSerializer.ts
+++ b/api/src/app/serializers/Room/LivingRoomSerializer.ts
@@ -1,8 +1,14 @@
-import { RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
+import { RoomForVisitorsSerializer, RoomSerializer, RoomSummarySerializer } from '#serializers/RoomSerializer.js'
import LivingRoom from '#models/Room/LivingRoom.js'
+import { type LocalesEnum } from '#src/types/db.js'

export const RoomLivingRoomSummarySerializer = (livingRoom: LivingRoom) =>
RoomSummarySerializer(LivingRoom, livingRoom)

export const RoomLivingRoomSerializer = (livingRoom: LivingRoom) =>
RoomSerializer(LivingRoom, livingRoom)
+
+export const RoomLivingRoomForVisitorsSerializer = (
+ roomLivingRoom: LivingRoom,
+ passthrough: { locale: LocalesEnum }
+) => RoomForVisitorsSerializer(LivingRoom, roomLivingRoom, passthrough)
diff --git a/api/src/app/serializers/RoomSerializer.ts b/api/src/app/serializers/RoomSerializer.ts
index 2da4337..d85eec8 100644
--- a/api/src/app/serializers/RoomSerializer.ts
+++ b/api/src/app/serializers/RoomSerializer.ts
@@ -1,11 +1,26 @@
import { DreamSerializer } from '@rvoh/dream'
import Room from '#models/Room.js'
+import { type LocalesEnum } from '#src/types/db.js'
+import i18n from '#src/utils/i18n.js'

export const RoomSummarySerializer = <T extends Room>(StiChildClass: typeof Room, room: T) =>
DreamSerializer(StiChildClass ?? Room, room)
.attribute('id')
.attribute('type', { openapi: { type: 'string', enum: [(StiChildClass ?? Room).sanitizedName] } })
+ .attribute('position')

export const RoomSerializer = <T extends Room>(StiChildClass: typeof Room, room: T) =>
RoomSummarySerializer(StiChildClass, room)
- .attribute('position')
+
+export const RoomForVisitorsSerializer = <T extends Room>(
+ StiChildClass: typeof Room,
+ room: T,
+ passthrough: { locale: LocalesEnum }
+) =>
+ DreamSerializer(StiChildClass ?? Room, room)
+ .attribute('id')
+ .attribute('type', { openapi: { type: 'string', enum: [(StiChildClass ?? Room).sanitizedName] } })
+ .customAttribute('displayType', () => i18n(passthrough.locale, `rooms.type.${room.type}`), {
+ openapi: 'string',
+ })
+ .delegatedAttribute('currentLocalizedText', 'title')
diff --git a/api/src/conf/locales/en.ts b/api/src/conf/locales/en.ts
index ae21584..1e720ed 100644
--- a/api/src/conf/locales/en.ts
+++ b/api/src/conf/locales/en.ts
@@ -1,3 +1,52 @@
export default {
- // add your application's localizable text in here
+ places: {
+ style: {
+ cottage: 'cottage',
+ cabin: 'cabin',
+ lean_to: 'lean to',
+ treehouse: 'treehouse',
+ tent: 'tent',
+ cave: 'cave',
+ dump: 'dump',
+ },
+ },
+
+ rooms: {
+ type: {
+ Bathroom: 'bathroom',
+ Bedroom: 'bedroom',
+ Kitchen: 'kitchen',
+ Den: 'den',
+ LivingRoom: 'living room',
+ },
+
+ Bathroom: {
+ bathOrShowerStyles: {
+ bath: 'bath',
+ shower: 'shower',
+ bath_and_shower: 'bath and shower',
+ none: 'none',
+ },
+ },
+
+ Bedroom: {
+ bedTypes: {
+ twin: 'twin',
+ bunk: 'bunk',
+ queen: 'queen',
+ king: 'king',
+ cot: 'cot',
+ sofabed: 'sofabed',
+ },
+ },
+
+ Kitchen: {
+ appliances: {
+ stove: 'stove',
+ oven: 'oven',
+ microwave: 'microwave',
+ dishwasher: 'dishwasher',
+ },
+ },
+ },
}
diff --git a/api/src/conf/locales/es.ts b/api/src/conf/locales/es.ts
new file mode 100644
index 0000000..25eb876
--- /dev/null
+++ b/api/src/conf/locales/es.ts
@@ -0,0 +1,52 @@
+export default {
+ places: {
+ style: {
+ cottage: 'cabaña',
+ cabin: 'cabaña rústica',
+ lean_to: 'refugio',
+ treehouse: 'casa del árbol',
+ tent: 'tienda de campaña',
+ cave: 'cueva',
+ dump: 'basurero',
+ },
+ },
+
+ rooms: {
+ type: {
+ Bathroom: 'baño',
+ Bedroom: 'dormitorio',
+ Kitchen: 'cocina',
+ Den: 'estudio',
+ LivingRoom: 'sala de estar',
+ },
+
+ Bathroom: {
+ bathOrShowerStyles: {
+ bath: 'bañera',
+ shower: 'ducha',
+ bath_and_shower: 'bañera y ducha',
+ none: 'ninguno',
+ },
+ },
+
+ Bedroom: {
+ bedTypes: {
+ twin: 'individual',
+ bunk: 'litera',
+ queen: 'matrimonial',
+ king: 'king',
+ cot: 'catre',
+ sofabed: 'sofá cama',
+ },
+ },
+
+ Kitchen: {
+ appliances: {
+ stove: 'estufa',
+ oven: 'horno',
+ microwave: 'microondas',
+ dishwasher: 'lavavajillas',
+ },
+ },
+ },
+}
diff --git a/api/src/conf/locales/index.ts b/api/src/conf/locales/index.ts
index df70a1a..e065634 100644
--- a/api/src/conf/locales/index.ts
+++ b/api/src/conf/locales/index.ts
@@ -1,5 +1,7 @@
import en from '#conf/locales/en.js'
+import es from '#conf/locales/es.js'

export default {
en,
+ es,
}
diff --git a/api/src/conf/routes.ts b/api/src/conf/routes.ts
index 013a229..295d6c2 100644
--- a/api/src/conf/routes.ts
+++ b/api/src/conf/routes.ts
@@ -1,15 +1,19 @@
import adminRoutes from '#conf/routes.admin.js'
import internalRoutes from '#conf/routes.internal.js'
+import VisitorV1PlacesController from '#controllers/Visitor/V1/PlacesController.js'
import { PsychicRouter } from '@rvoh/psychic'

export default function routes(r: PsychicRouter) {
r.namespace('v1', r => {
+ // Visitor/V1 controllers are maybe-authed, so they sit outside the authed V1/ controller tree
+ // and are routed here with an explicit controller to keep "visitor" out of the URL
+ r.resources('places', { only: ['index', 'show'], controller: VisitorV1PlacesController })
+
r.namespace('host', r => {
r.resources('localized-texts', { only: ['update', 'destroy'] })

r.resources('places', r => {
r.resources('rooms')
-
})
})
})
diff --git a/api/src/openapi/mobile.openapi.json b/api/src/openapi/mobile.openapi.json
index 7ebe66d..1e753d3 100644
--- a/api/src/openapi/mobile.openapi.json
+++ b/api/src/openapi/mobile.openapi.json
@@ -781,10 +781,177 @@
}
}
}
+ },
+ "/v1/places": {
+ "parameters": [
+ {
+ "in": "query",
+ "required": false,
+ "name": "cursor",
+ "description": "Pagination cursor",
+ "allowReserved": true,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place index endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "cursor",
+ "results"
+ ],
+ "properties": {
+ "cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PlaceSummaryForVisitors"
+ }
+ }
+ }
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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/places/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place show endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlaceForVisitors"
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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"
+ }
+ }
+ }
}
},
"components": {
"schemas": {
+ "Appliance": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "description": "\nThe following values will be allowed:\n dishwasher,\n microwave,\n oven,\n stove"
+ }
+ }
+ },
+ "BathOrShowerStyle": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "description": "\nThe following values will be allowed:\n bath,\n bath_and_shower,\n none,\n shower"
+ }
+ }
+ },
+ "BedType": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "description": "\nThe following values will be allowed:\n bunk,\n cot,\n king,\n queen,\n sofabed,\n twin"
+ }
+ }
+ },
"OpenapiValidationErrors": {
"type": "object",
"required": [
@@ -864,6 +1031,60 @@
}
}
},
+ "PlaceForVisitors": {
+ "type": "object",
+ "required": [
+ "displayStyle",
+ "id",
+ "rooms",
+ "sleeps",
+ "style",
+ "title"
+ ],
+ "properties": {
+ "displayStyle": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "rooms": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/RoomBathroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomBedroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomDenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomKitchenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomLivingRoomForVisitors"
+ }
+ ]
+ }
+ },
+ "sleeps": {
+ "type": "integer"
+ },
+ "style": {
+ "type": "string",
+ "description": "The following values will be allowed:\n cabin,\n cave,\n cottage,\n dump,\n lean_to,\n tent,\n treehouse"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"PlaceSummary": {
"type": "object",
"required": [
@@ -879,6 +1100,24 @@
}
}
},
+ "PlaceSummaryForVisitors": {
+ "type": "object",
+ "required": [
+ "id",
+ "title"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"RoomBathroom": {
"type": "object",
"required": [
@@ -910,16 +1149,54 @@
}
}
},
+ "RoomBathroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bathOrShowerStyle",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bathOrShowerStyle": {
+ "$ref": "#/components/schemas/BathOrShowerStyle"
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "description": "The following values will be allowed:\n Bathroom"
+ }
+ }
+ },
"RoomBathroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"description": "The following values will be allowed:\n Bathroom"
@@ -957,16 +1234,57 @@
}
}
},
+ "RoomBedroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bedTypes",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bedTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BedType"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "description": "The following values will be allowed:\n Bedroom"
+ }
+ }
+ },
"RoomBedroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"description": "The following values will be allowed:\n Bedroom"
@@ -996,16 +1314,50 @@
}
}
},
+ "RoomDenForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "description": "The following values will be allowed:\n Den"
+ }
+ }
+ },
"RoomDenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"description": "The following values will be allowed:\n Den"
@@ -1043,16 +1395,57 @@
}
}
},
+ "RoomKitchenForVisitors": {
+ "type": "object",
+ "required": [
+ "appliances",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "appliances": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Appliance"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "description": "The following values will be allowed:\n Kitchen"
+ }
+ }
+ },
"RoomKitchenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"description": "The following values will be allowed:\n Kitchen"
@@ -1082,16 +1475,50 @@
}
}
},
+ "RoomLivingRoomForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "description": "The following values will be allowed:\n LivingRoom"
+ }
+ }
+ },
"RoomLivingRoomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"description": "The following values will be allowed:\n LivingRoom"
diff --git a/api/src/openapi/openapi.json b/api/src/openapi/openapi.json
index 9bfe022..3dbd19e 100644
--- a/api/src/openapi/openapi.json
+++ b/api/src/openapi/openapi.json
@@ -781,10 +781,194 @@
}
}
}
+ },
+ "/v1/places": {
+ "parameters": [
+ {
+ "in": "query",
+ "required": false,
+ "name": "cursor",
+ "description": "Pagination cursor",
+ "allowReserved": true,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place index endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "cursor",
+ "results"
+ ],
+ "properties": {
+ "cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PlaceSummaryForVisitors"
+ }
+ }
+ }
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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/places/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place show endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlaceForVisitors"
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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"
+ }
+ }
+ }
}
},
"components": {
"schemas": {
+ "Appliance": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "dishwasher",
+ "microwave",
+ "oven",
+ "stove"
+ ]
+ }
+ }
+ },
+ "BathOrShowerStyle": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "bath",
+ "bath_and_shower",
+ "none",
+ "shower"
+ ]
+ }
+ }
+ },
+ "BedType": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "bunk",
+ "cot",
+ "king",
+ "queen",
+ "sofabed",
+ "twin"
+ ]
+ }
+ }
+ },
"OpenapiValidationErrors": {
"type": "object",
"required": [
@@ -872,6 +1056,68 @@
}
}
},
+ "PlaceForVisitors": {
+ "type": "object",
+ "required": [
+ "displayStyle",
+ "id",
+ "rooms",
+ "sleeps",
+ "style",
+ "title"
+ ],
+ "properties": {
+ "displayStyle": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "rooms": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/RoomBathroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomBedroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomDenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomKitchenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomLivingRoomForVisitors"
+ }
+ ]
+ }
+ },
+ "sleeps": {
+ "type": "integer"
+ },
+ "style": {
+ "type": "string",
+ "enum": [
+ "cabin",
+ "cave",
+ "cottage",
+ "dump",
+ "lean_to",
+ "tent",
+ "treehouse"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"PlaceSummary": {
"type": "object",
"required": [
@@ -887,6 +1133,24 @@
}
}
},
+ "PlaceSummaryForVisitors": {
+ "type": "object",
+ "required": [
+ "id",
+ "title"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"RoomBathroom": {
"type": "object",
"required": [
@@ -926,16 +1190,56 @@
}
}
},
+ "RoomBathroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bathOrShowerStyle",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bathOrShowerStyle": {
+ "$ref": "#/components/schemas/BathOrShowerStyle"
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Bathroom"
+ ]
+ }
+ }
+ },
"RoomBathroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -984,16 +1288,59 @@
}
}
},
+ "RoomBedroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bedTypes",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bedTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BedType"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Bedroom"
+ ]
+ }
+ }
+ },
"RoomBedroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1027,16 +1374,52 @@
}
}
},
+ "RoomDenForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Den"
+ ]
+ }
+ }
+ },
"RoomDenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1083,16 +1466,59 @@
}
}
},
+ "RoomKitchenForVisitors": {
+ "type": "object",
+ "required": [
+ "appliances",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "appliances": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Appliance"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Kitchen"
+ ]
+ }
+ }
+ },
"RoomKitchenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1126,16 +1552,52 @@
}
}
},
+ "RoomLivingRoomForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "LivingRoom"
+ ]
+ }
+ }
+ },
"RoomLivingRoomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
diff --git a/api/src/openapi/tests.openapi.json b/api/src/openapi/tests.openapi.json
index e57825d..700c4c4 100644
--- a/api/src/openapi/tests.openapi.json
+++ b/api/src/openapi/tests.openapi.json
@@ -781,10 +781,194 @@
}
}
}
+ },
+ "/v1/places": {
+ "parameters": [
+ {
+ "in": "query",
+ "required": false,
+ "name": "cursor",
+ "description": "Pagination cursor",
+ "allowReserved": true,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place index endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "cursor",
+ "results"
+ ],
+ "properties": {
+ "cursor": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PlaceSummaryForVisitors"
+ }
+ }
+ }
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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/places/{id}": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "visitor-places"
+ ],
+ "description": "Place show endpoint for Visitors",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlaceForVisitors"
+ }
+ }
+ },
+ "description": "Success"
+ },
+ "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"
+ }
+ }
+ }
}
},
"components": {
"schemas": {
+ "Appliance": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "dishwasher",
+ "microwave",
+ "oven",
+ "stove"
+ ]
+ }
+ }
+ },
+ "BathOrShowerStyle": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "bath",
+ "bath_and_shower",
+ "none",
+ "shower"
+ ]
+ }
+ }
+ },
+ "BedType": {
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "enum": [
+ "bunk",
+ "cot",
+ "king",
+ "queen",
+ "sofabed",
+ "twin"
+ ]
+ }
+ }
+ },
"OpenapiValidationErrors": {
"type": "object",
"required": [
@@ -872,6 +1056,68 @@
}
}
},
+ "PlaceForVisitors": {
+ "type": "object",
+ "required": [
+ "displayStyle",
+ "id",
+ "rooms",
+ "sleeps",
+ "style",
+ "title"
+ ],
+ "properties": {
+ "displayStyle": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "rooms": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/RoomBathroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomBedroomForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomDenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomKitchenForVisitors"
+ },
+ {
+ "$ref": "#/components/schemas/RoomLivingRoomForVisitors"
+ }
+ ]
+ }
+ },
+ "sleeps": {
+ "type": "integer"
+ },
+ "style": {
+ "type": "string",
+ "enum": [
+ "cabin",
+ "cave",
+ "cottage",
+ "dump",
+ "lean_to",
+ "tent",
+ "treehouse"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"PlaceSummary": {
"type": "object",
"required": [
@@ -887,6 +1133,24 @@
}
}
},
+ "PlaceSummaryForVisitors": {
+ "type": "object",
+ "required": [
+ "id",
+ "title"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"RoomBathroom": {
"type": "object",
"required": [
@@ -926,16 +1190,56 @@
}
}
},
+ "RoomBathroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bathOrShowerStyle",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bathOrShowerStyle": {
+ "$ref": "#/components/schemas/BathOrShowerStyle"
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Bathroom"
+ ]
+ }
+ }
+ },
"RoomBathroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -984,16 +1288,59 @@
}
}
},
+ "RoomBedroomForVisitors": {
+ "type": "object",
+ "required": [
+ "bedTypes",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "bedTypes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BedType"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Bedroom"
+ ]
+ }
+ }
+ },
"RoomBedroomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1027,16 +1374,52 @@
}
}
},
+ "RoomDenForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Den"
+ ]
+ }
+ }
+ },
"RoomDenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1083,16 +1466,59 @@
}
}
},
+ "RoomKitchenForVisitors": {
+ "type": "object",
+ "required": [
+ "appliances",
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "appliances": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Appliance"
+ }
+ },
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "Kitchen"
+ ]
+ }
+ }
+ },
"RoomKitchenSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
@@ -1126,16 +1552,52 @@
}
}
},
+ "RoomLivingRoomForVisitors": {
+ "type": "object",
+ "required": [
+ "displayType",
+ "id",
+ "title",
+ "type"
+ ],
+ "properties": {
+ "displayType": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "LivingRoom"
+ ]
+ }
+ }
+ },
"RoomLivingRoomSummary": {
"type": "object",
"required": [
"id",
+ "position",
"type"
],
"properties": {
"id": {
"type": "string"
},
+ "position": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"type": {
"type": "string",
"enum": [
diff --git a/api/src/types/dream.globals.ts b/api/src/types/dream.globals.ts
index 0f826c1..f8f3481 100644
--- a/api/src/types/dream.globals.ts
+++ b/api/src/types/dream.globals.ts
@@ -64,18 +64,29 @@ export const globalTypeConfig = {
'HostSummarySerializer',
'LocalizedTextSerializer',
'LocalizedTextSummarySerializer',
+ 'PlaceForVisitorsSerializer',
'PlaceSerializer',
+ 'PlaceSummaryForVisitorsSerializer',
'PlaceSummarySerializer',
+ 'Room/ApplianceSerializer',
+ 'Room/BathOrShowerStyleSerializer',
+ 'Room/BathroomForVisitorsSerializer',
'Room/BathroomSerializer',
'Room/BathroomSummarySerializer',
+ 'Room/BedTypeSerializer',
+ 'Room/BedroomForVisitorsSerializer',
'Room/BedroomSerializer',
'Room/BedroomSummarySerializer',
+ 'Room/DenForVisitorsSerializer',
'Room/DenSerializer',
'Room/DenSummarySerializer',
+ 'Room/KitchenForVisitorsSerializer',
'Room/KitchenSerializer',
'Room/KitchenSummarySerializer',
+ 'Room/LivingRoomForVisitorsSerializer',
'Room/LivingRoomSerializer',
'Room/LivingRoomSummarySerializer',
+ 'RoomForVisitorsSerializer',
'RoomSerializer',
'RoomSummarySerializer',
],
diff --git a/api/src/types/dream.ts b/api/src/types/dream.ts
index d73f541..c881e17 100644
--- a/api/src/types/dream.ts
+++ b/api/src/types/dream.ts
@@ -456,7 +456,7 @@ export const schema = {
},
},
places: {
- serializerKeys: ['default', 'summary'],
+ serializerKeys: ['default', 'forVisitors', 'summary', 'summaryForVisitors'],
scopes: {
default: ['dream:SoftDelete'],
named: [],
@@ -585,7 +585,7 @@ export const schema = {
},
},
rooms: {
- serializerKeys: ['default', 'summary'],
+ serializerKeys: ['default', 'forVisitors', 'summary'],
scopes: {
default: ['dream:STI', 'dream:SoftDelete'],
named: [],
diff --git a/api/src/types/openapi/tests.openapi.d.ts b/api/src/types/openapi/tests.openapi.d.ts
index 5447e50..cdcb536 100644
--- a/api/src/types/openapi/tests.openapi.d.ts
+++ b/api/src/types/openapi/tests.openapi.d.ts
@@ -454,10 +454,122 @@ export interface paths {
};
trace?: never;
};
+ "/v1/places": {
+ parameters: {
+ query?: {
+ /** @description Pagination cursor */
+ cursor?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** @description Place index endpoint for Visitors */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Pagination cursor */
+ cursor?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ cursor: string | null;
+ results: components["schemas"]["PlaceSummaryForVisitors"][];
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 500: components["responses"]["InternalServerError"];
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/places/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ /** @description Place show endpoint for Visitors */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Success */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PlaceForVisitors"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 500: components["responses"]["InternalServerError"];
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
+ Appliance: {
+ label: string;
+ /** @enum {string} */
+ value: "dishwasher" | "microwave" | "oven" | "stove";
+ };
+ BathOrShowerStyle: {
+ label: string;
+ /** @enum {string} */
+ value: "bath" | "bath_and_shower" | "none" | "shower";
+ };
+ BedType: {
+ label: string;
+ /** @enum {string} */
+ value: "bunk" | "cot" | "king" | "queen" | "sofabed" | "twin";
+ };
OpenapiValidationErrors: {
/** @enum {string} */
type: "openapi";
@@ -478,10 +590,23 @@ export interface components {
/** @enum {string} */
style: "cabin" | "cave" | "cottage" | "dump" | "lean_to" | "tent" | "treehouse";
};
+ PlaceForVisitors: {
+ displayStyle: string;
+ id: string;
+ rooms: (components["schemas"]["RoomBathroomForVisitors"] | components["schemas"]["RoomBedroomForVisitors"] | components["schemas"]["RoomDenForVisitors"] | components["schemas"]["RoomKitchenForVisitors"] | components["schemas"]["RoomLivingRoomForVisitors"])[];
+ sleeps: number;
+ /** @enum {string} */
+ style: "cabin" | "cave" | "cottage" | "dump" | "lean_to" | "tent" | "treehouse";
+ title: string | null;
+ };
PlaceSummary: {
id: string;
name: string;
};
+ PlaceSummaryForVisitors: {
+ id: string;
+ title: string | null;
+ };
RoomBathroom: {
/** @enum {string|null} */
bathOrShowerStyle: "bath" | "bath_and_shower" | "none" | "shower" | null;
@@ -490,8 +615,17 @@ export interface components {
/** @enum {string} */
type: "Bathroom";
};
+ RoomBathroomForVisitors: {
+ bathOrShowerStyle: components["schemas"]["BathOrShowerStyle"];
+ displayType: string;
+ id: string;
+ title: string | null;
+ /** @enum {string} */
+ type: "Bathroom";
+ };
RoomBathroomSummary: {
id: string;
+ position: number | null;
/** @enum {string} */
type: "Bathroom";
};
@@ -502,8 +636,17 @@ export interface components {
/** @enum {string} */
type: "Bedroom";
};
+ RoomBedroomForVisitors: {
+ bedTypes: components["schemas"]["BedType"][];
+ displayType: string;
+ id: string;
+ title: string | null;
+ /** @enum {string} */
+ type: "Bedroom";
+ };
RoomBedroomSummary: {
id: string;
+ position: number | null;
/** @enum {string} */
type: "Bedroom";
};
@@ -513,8 +656,16 @@ export interface components {
/** @enum {string} */
type: "Den";
};
+ RoomDenForVisitors: {
+ displayType: string;
+ id: string;
+ title: string | null;
+ /** @enum {string} */
+ type: "Den";
+ };
RoomDenSummary: {
id: string;
+ position: number | null;
/** @enum {string} */
type: "Den";
};
@@ -525,8 +676,17 @@ export interface components {
/** @enum {string} */
type: "Kitchen";
};
+ RoomKitchenForVisitors: {
+ appliances: components["schemas"]["Appliance"][];
+ displayType: string;
+ id: string;
+ title: string | null;
+ /** @enum {string} */
+ type: "Kitchen";
+ };
RoomKitchenSummary: {
id: string;
+ position: number | null;
/** @enum {string} */
type: "Kitchen";
};
@@ -536,8 +696,16 @@ export interface components {
/** @enum {string} */
type: "LivingRoom";
};
+ RoomLivingRoomForVisitors: {
+ displayType: string;
+ id: string;
+ title: string | null;
+ /** @enum {string} */
+ type: "LivingRoom";
+ };
RoomLivingRoomSummary: {
id: string;
+ position: number | null;
/** @enum {string} */
type: "LivingRoom";
};
diff --git a/api/src/utils/i18n.ts b/api/src/utils/i18n.ts
index c52c0e5..40b8871 100644
--- a/api/src/utils/i18n.ts
+++ b/api/src/utils/i18n.ts
@@ -1,15 +1,15 @@
import locales from '#conf/locales/index.js'
import { I18nProvider } from '@rvoh/psychic/system'
+import { LocalesEnumValues } from '#src/types/db.js'

-// The request language tags the app accepts. ApplicationController's locale
-// getter picks the one a request's Accept-Language header prefers. Each is
-// served by its language's locale file ('en-US' by 'en'). An app with a
-// database enum of locales returns that enum's values here instead (Dream's
-// src/types/db.ts exports them, as in LocalesEnumValues). Their order does
-// not matter: Dream lists an enum's values alphabetically
-const SUPPORTED_LOCALES = ['en-US'] as const
+// The request language tags the app accepts: the database enum of locales
+// (src/types/db.ts), so the supported locales are defined once, in the
+// migration. ApplicationController's locale getter picks the one a request's
+// Accept-Language header prefers. Each is served by its language's locale file
+// ('en-US' by 'en', 'es-ES' by 'es'). Their order does not matter: Dream lists
+// an enum's values alphabetically
export function supportedLocales() {
- return SUPPORTED_LOCALES
+ return LocalesEnumValues
}

// The supported locale a request gets when its Accept-Language header is