From 8c7352a1df1ddc58effd09fda2904104a3d5fb5b Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 30 Apr 2024 14:39:45 -0400 Subject: [PATCH] feat: crowdin updating (#1246) # Pull Request type Please check the type of change your PR introduces: - [ ] Bugfix - [x] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build-related changes - [ ] Documentation content changes - [ ] Other (please describe): Issue Number: IN-920 ## Does this introduce a breaking change? - [ ] Yes - [ ] No ## Other information --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .github/renovate.json | 4 +- apps/app/lib/generate.ts | 9 +- apps/app/lib/generators/translationKeys.ts | 86 ++-- apps/app/package.json | 4 +- apps/app/src/pages/api/i18n/load.ts | 20 +- .../orgEmail/mutation.create.handler.ts | 21 +- .../router/orgEmail/mutation.create.schema.ts | 38 +- .../orgEmail/mutation.update.handler.ts | 110 +++-- .../orgEmail/mutation.upsertMany.handler.ts | 107 +++-- packages/api/router/orgPhone/index.ts | 10 +- .../orgPhone/mutation.create.handler.ts | 35 +- .../router/orgPhone/mutation.create.schema.ts | 36 +- .../orgPhone/mutation.update.handler.ts | 51 +- .../orgPhone/mutation.upsertMany.handler.ts | 85 ---- .../orgPhone/mutation.upsertMany.schema.ts | 24 - packages/api/router/orgPhone/schemas.ts | 1 - .../orgWebsite/mutation.create.handler.ts | 40 +- .../orgWebsite/mutation.create.schema.ts | 44 +- .../orgWebsite/mutation.update.handler.ts | 70 ++- .../orgWebsite/mutation.update.schema.ts | 52 +- .../orgWebsite/mutation.upsert.handler.ts | 107 +++-- .../mutation.attachAttribute.handler.ts | 45 +- .../mutation.createNewQuick.handler.ts | 12 +- .../mutation.createNewSuggestion.handler.ts | 29 +- .../mutation.createNewSuggestion.schema.ts | 61 +-- .../mutation.updateBasic.handler.ts | 59 +-- .../router/service/mutation.create.handler.ts | 46 +- .../router/service/mutation.create.schema.ts | 47 +- ...tation.createAccessInstructions.handler.ts | 9 + .../router/service/mutation.upsert.handler.ts | 129 ++--- packages/crowdin/api/index.ts | 42 +- packages/crowdin/cache/index.ts | 32 +- packages/crowdin/common/apiFns.ts | 218 ++++++++- packages/crowdin/common/otaFns.ts | 15 +- packages/crowdin/common/updateOpts.ts | 50 -- packages/crowdin/constants.ts | 174 +++++-- packages/crowdin/ota/edge.ts | 32 +- packages/crowdin/ota/index.ts | 40 +- packages/crowdin/package.json | 4 +- packages/db/lib/generateFreeText.ts | 70 ++- packages/db/package.json | 3 + packages/db/prisma/common.ts | 9 +- .../2024-04-24_update-crowdin-ids.ts | 82 ++++ .../2024-04-25_translation-activation-flag.ts | 79 ++++ packages/db/prisma/data-migrations/index.ts | 2 + .../migration.sql | 57 +++ .../migration.sql | 3 + .../migration.sql | 10 + packages/db/prisma/schema.prisma | 12 +- .../generators/templates/dataMigration.hbs | 4 +- .../data-portal/EmailTableDrawer.stories.tsx | 46 -- .../data-portal/EmailTableDrawer.tsx | 445 ------------------ .../data-portal/PhoneTableDrawer.stories.tsx | 46 -- .../data-portal/PhoneTableDrawer.tsx | 435 ----------------- pnpm-lock.yaml | 15 + 55 files changed, 1499 insertions(+), 1817 deletions(-) delete mode 100644 packages/api/router/orgPhone/mutation.upsertMany.handler.ts delete mode 100644 packages/api/router/orgPhone/mutation.upsertMany.schema.ts delete mode 100644 packages/crowdin/common/updateOpts.ts create mode 100644 packages/db/prisma/data-migrations/2024-04-24_update-crowdin-ids.ts create mode 100644 packages/db/prisma/data-migrations/2024-04-25_translation-activation-flag.ts create mode 100644 packages/db/prisma/migrations/20240425151405_add_suggested_by/migration.sql create mode 100644 packages/db/prisma/migrations/20240425164515_translation_active_flag/migration.sql create mode 100644 packages/db/prisma/migrations/20240425164913_overwrite_file_on_export_flag/migration.sql delete mode 100644 packages/ui/components/data-portal/EmailTableDrawer.stories.tsx delete mode 100644 packages/ui/components/data-portal/EmailTableDrawer.tsx delete mode 100644 packages/ui/components/data-portal/PhoneTableDrawer.stories.tsx delete mode 100644 packages/ui/components/data-portal/PhoneTableDrawer.tsx diff --git a/.github/renovate.json b/.github/renovate.json index 8cd115edda8..7550603e1f4 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,14 +6,14 @@ "packageRules": [ { "groupName": "patched packages", - "matchPackageNames": ["@crowdin/ota-client", "trpc-panel", "json-schema-to-zod"], + "matchDepNames": ["@crowdin/ota-client", "trpc-panel", "json-schema-to-zod"], "matchUpdateTypes": ["major", "minor", "patch"] }, { "enabled": false, "groupName": "Ignored Versions", "matchCurrentVersion": "0.9.2", - "matchPackageNames": ["@t3-oss/env-nextjs"] + "matchDepNames": ["@t3-oss/env-nextjs"] } ], "semanticCommitScope": "{{parentDir}}" diff --git a/apps/app/lib/generate.ts b/apps/app/lib/generate.ts index 7dbe4a57649..aa58d132cdf 100644 --- a/apps/app/lib/generate.ts +++ b/apps/app/lib/generate.ts @@ -14,19 +14,21 @@ import { generateTranslationKeys } from 'lib/generators' const program = new Command() export type PassedTask = ListrTaskWrapper +type TaskDef = ListrTask -const options = { +const rendererOptions: TaskDef['rendererOptions'] = { bottomBar: 10, persistentOutput: true, + outputBar: true, } const translation = [ { title: 'Translation definitions from DB', task: (_ctx: ListrContext, task: PassedTask) => generateTranslationKeys(task), skip: !process.env.DATABASE_URL, - options, + rendererOptions, }, -] +] satisfies TaskDef[] program .name('generate') @@ -47,6 +49,7 @@ if (Object.keys(cliOpts).length === 0) { const tasks = new Listr(tasklist, { exitOnError: false, + rendererOptions: { collapseSubtasks: false }, }) tasks.run() diff --git a/apps/app/lib/generators/translationKeys.ts b/apps/app/lib/generators/translationKeys.ts index a04a283de0d..ec8970b44f0 100644 --- a/apps/app/lib/generators/translationKeys.ts +++ b/apps/app/lib/generators/translationKeys.ts @@ -21,27 +21,28 @@ const isObject = (data: unknown): data is Record => const countKeys = (obj: Output): number => Object.keys(flatten(obj)).length -export const generateTranslationKeys = async (task: PassedTask) => { - const prettierOpts = (await prettier.resolveConfig(__filename)) ?? undefined - - const where = (): Prisma.TranslationNamespaceWhereInput | undefined => { - switch (true) { - case !!process.env.EXPORT_ALL: { - return undefined - } - case !!process.env.EXPORT_DB: { - return { name: 'org-data' } - } - default: { - return { exportFile: true } +const where = (): Prisma.TranslationNamespaceWhereInput | undefined => { + switch (true) { + case !!process.env.EXPORT_ALL: { + return undefined + } + case !!process.env.EXPORT_DB: { + return { + name: 'org-data', } } + default: { + return { exportFile: true } + } } +} - const data = await prisma.translationNamespace.findMany({ +const getKeysFromDb = async () => + await prisma.translationNamespace.findMany({ where: where(), include: { keys: { + ...(!process.env.EXPORT_INACTIVE && { where: { active: true } }), orderBy: { key: 'asc', }, @@ -51,52 +52,59 @@ export const generateTranslationKeys = async (task: PassedTask) => { name: 'asc', }, }) + +type DBKeys = Prisma.PromiseReturnType[number]['keys'] + +const processKeys = (keys: DBKeys) => { + const outputData: Output = {} + for (const item of keys) { + if (item.interpolation && isObject(item.interpolationValues)) { + for (const [context, textContent] of Object.entries(item.interpolationValues)) { + if (typeof textContent !== 'string') { + throw new Error('Invalid nested plural item') + } + outputData[`${item.key}_${context}`] = textContent + } + } + if (!item.interpolation || item.interpolation === 'CONTEXT') { + outputData[item.key] = item.text + } + } + return outputData +} + +export const generateTranslationKeys = async (task: PassedTask) => { + const prettierConfig = (await prettier.resolveConfig(__filename, { editorconfig: true })) ?? undefined + const prettierOpts = prettierConfig ? { ...prettierConfig, parser: 'json' } : undefined + const data = await getKeysFromDb() let logMessage = '' let i = 0 task.output = `Fetched ${data.length} namespaces from DB` for (const namespace of data) { - const outputData: Output = {} - for (const item of namespace.keys) { - if (item.interpolation && isObject(item.interpolationValues)) { - for (const [key, value] of Object.entries(item.interpolationValues)) { - if (typeof value !== 'string') { - throw new Error('Invalid nested plural item') - } - outputData[`${item.key}_${key}`] = value - } - } - if (item.ns === 'attribute') { - outputData[item.key] = item.text - } - } + const outputData = processKeys(namespace.keys) const filename = `${localePath}/${namespace.name}.json` let existingFile: unknown = {} - if (fs.existsSync(filename)) { + if (fs.existsSync(filename) && !namespace.overwriteFileOnExport) { existingFile = flatten(JSON.parse(fs.readFileSync(filename, 'utf-8'))) } if (!isOutput(existingFile)) { throw new Error("tried to load file, but it's empty") } - // const existingLength = Object.keys(existingFile).length + const existingLength = countKeys(existingFile) let outputFile: Output = unflatten(Object.assign(existingFile, outputData), { overwrite: true }) outputFile = Object.keys(outputFile) - .sort((a, b) => a.localeCompare(b)) + .toSorted((a, b) => a.localeCompare(b)) .reduce((obj: Record, key) => { obj[key] = outputFile[key] as string return obj }, {}) const newKeys = countKeys(outputFile) - existingLength - logMessage = `${filename} generated with ${newKeys} new ${newKeys === 1 ? 'key' : 'keys'}.` - - const formattedOutput = await prettier.format(JSON.stringify(outputFile), { - ...prettierOpts, - parser: 'json', - }) - fs.writeFileSync(filename, formattedOutput) - + logMessage = `${filename} generated with ${newKeys} ${namespace.overwriteFileOnExport ? 'total' : 'new'} ${newKeys === 1 ? 'key' : 'keys'}.` + const formattedOutput = await prettier.format(JSON.stringify(outputFile), prettierOpts) + fs.writeFileSync(filename, formattedOutput, 'utf-8') task.output = logMessage i++ } diff --git a/apps/app/package.json b/apps/app/package.json index cd95615d6f1..993ba110182 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,8 +13,8 @@ "postdev": "pnpm -w docker:down", "dev:verbose": "NEXT_VERBOSE=1 next dev", "format": "prettier --write --ignore-unknown .", - "generate:all": "tsx ./lib/generate.ts", - "generate:i18n": "tsx ./lib/generate.ts -t", + "generate:all": "pnpm with-env tsx ./lib/generate.ts", + "generate:i18n": "pnpm with-env tsx ./lib/generate.ts -t", "generate:i18nTypes": "i18next-resources-for-ts interface -i ./public/locales/en -o ./src/types/resources.d.ts", "preinstall": "npx only-allow pnpm", "lint": "next lint", diff --git a/apps/app/src/pages/api/i18n/load.ts b/apps/app/src/pages/api/i18n/load.ts index 9ee6a2d6105..264c214dc87 100644 --- a/apps/app/src/pages/api/i18n/load.ts +++ b/apps/app/src/pages/api/i18n/load.ts @@ -17,8 +17,8 @@ export const config = { } const QuerySchema = z.object({ - lng: z.string(), - ns: z.string(), + lng: z.string().transform((s) => s.split(' ')), + ns: z.string().transform((s) => s.split(' ')), }) const tracer = trace.getTracer('inreach-app') const log = createLoggerInstance('i18n Loader') @@ -34,10 +34,9 @@ export default async function handler(req: NextRequest) { }) } const query = parsedQuery.data - const namespaces = query.ns.split(' ') - const langs = query.lng.split(' ') + const { ns: namespaces, lng: langs } = query const cacheWriteQueue: WriteCacheArgs[] = [] - const otaManifestTimestamp = await crowdinDistTimestamp() + const otaManifestTimestamps = await crowdinDistTimestamp() const results = new Map() for (const lang of langs) { @@ -46,8 +45,7 @@ export default async function handler(req: NextRequest) { if (lang === 'en') { continue } - const databaseFile = sourceFiles(lang).databaseStrings - const cached = await redisReadCache(namespaces, lang, otaManifestTimestamp) + const cached = await redisReadCache(namespaces, lang, otaManifestTimestamps) const langResult = new Map(cached) const fetchCrowdin = async (ns: string) => { @@ -55,10 +53,12 @@ export default async function handler(req: NextRequest) { try { crowdinSpan.setAttributes({ ns }) switch (true) { + // Check if the namespace is already in the cache case langResult.has(ns): { return } - case Object.hasOwn(nsFileMap, ns): { + // Check if the namespace is file based + case ns in nsFileMap: { const file = nsFileMap[ns as keyof typeof nsFileMap] ?? '' const strings = await fetchCrowdinFile(file, lang) if (strings && Object.keys(strings).length) { @@ -67,9 +67,9 @@ export default async function handler(req: NextRequest) { langResult.set(ns, strings) break } + // Otherwise, it must be a database key default: { - const file = databaseFile - const strings = await fetchCrowdinDbKey(ns, file, lang) + const strings = await fetchCrowdinDbKey(ns, lang) if (strings) { cacheWriteQueue.push({ lang, ns, strings }) } diff --git a/packages/api/router/orgEmail/mutation.create.handler.ts b/packages/api/router/orgEmail/mutation.create.handler.ts index 3d00abb8389..c712342f58b 100644 --- a/packages/api/router/orgEmail/mutation.create.handler.ts +++ b/packages/api/router/orgEmail/mutation.create.handler.ts @@ -1,3 +1,4 @@ +import { addSingleKey } from '@weareinreach/crowdin/api' import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' @@ -5,10 +6,22 @@ import { type TCreateSchema } from './mutation.create.schema' const create = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) - const newEmail = await prisma.orgEmail.create({ - data: input, - select: { id: true }, + + const result = await prisma.$transaction(async (tx) => { + if (input.description) { + const crowdinId = await addSingleKey({ + isDatabaseString: true, + key: input.description.create.tsKey.create.key, + text: input.description.create.tsKey.create.text, + }) + input.description.create.tsKey.create.crowdinId = crowdinId.id + } + const newEmail = await tx.orgEmail.create({ + data: input, + select: { id: true }, + }) + return newEmail }) - return newEmail + return result } export default create diff --git a/packages/api/router/orgEmail/mutation.create.schema.ts b/packages/api/router/orgEmail/mutation.create.schema.ts index 8966359ebad..0b139eddb45 100644 --- a/packages/api/router/orgEmail/mutation.create.schema.ts +++ b/packages/api/router/orgEmail/mutation.create.schema.ts @@ -26,27 +26,33 @@ export const ZCreateSchema = z .transform(({ orgId, data, title, titleId, description }) => { const id = generateId('orgEmail') + const handleTitle = () => { + if (title) { + return { + create: { + title, + key: { + create: { + text: title, + key: slug(title), + namespace: { connect: { name: namespace.userTitle } }, + }, + }, + }, + } + } + if (titleId) { + return { connect: { id: titleId } } + } + return undefined + } + return Prisma.validator()({ ...data, description: description ? generateNestedFreeText({ orgId, itemId: id, text: description, type: 'emailDesc' }) : undefined, - title: title - ? { - create: { - title, - key: { - create: { - text: title, - key: slug(title), - namespace: { connect: { name: namespace.userTitle } }, - }, - }, - }, - } - : titleId - ? { connect: { id: titleId } } - : undefined, + title: handleTitle(), }) }) export type TCreateSchema = z.infer diff --git a/packages/api/router/orgEmail/mutation.update.handler.ts b/packages/api/router/orgEmail/mutation.update.handler.ts index c0c5405d06e..5e4bb688ecc 100644 --- a/packages/api/router/orgEmail/mutation.update.handler.ts +++ b/packages/api/router/orgEmail/mutation.update.handler.ts @@ -1,8 +1,23 @@ -import { generateNestedFreeText, generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' +import { upsertSingleKey } from '@weareinreach/crowdin/api' +import { generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' +const select = { + id: true, + deleted: true, + description: { select: { tsKey: { select: { text: true, key: true, ns: true } } } }, + descriptionId: true, + email: true, + firstName: true, + lastName: true, + locationOnly: true, + primary: true, + published: true, + serviceOnly: true, + titleId: true, +} as const const update = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) const { id, orgId, description, descriptionId, titleId, email, linkLocationId, ...record } = input @@ -17,58 +32,49 @@ const update = async ({ ctx, input }: TRPCHandlerParams { + if (updateDescriptionText) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + key: updateDescriptionText.upsert.create.tsKey.create.key, + text: updateDescriptionText.upsert.create.tsKey.create.text, }) - const { description: updatedDescription, ...rest } = updated + if (crowdin.id) { + updateDescriptionText.upsert.create.tsKey.create.crowdinId = crowdin.id + } + } + const updated = email + ? await tx.orgEmail.upsert({ + where: { id }, + create: { + id, + email, + ...record, + description: updateDescriptionText?.upsert, + ...(linkLocationId && { + locations: { createMany: { data: [{ orgLocationId: linkLocationId }], skipDuplicates: true } }, + }), + }, + update: { + ...record, + description: updateDescriptionText, + title: titleId ? { connect: { id: titleId } } : undefined, + }, + select, + }) + : await tx.orgEmail.update({ + where: { id }, + data: { + ...record, + description: updateDescriptionText, + title: titleId ? { connect: { id: titleId } } : undefined, + }, + select, + }) + return updated + }) + + const { description: updatedDescription, ...rest } = result const reformatted = { ...rest, diff --git a/packages/api/router/orgEmail/mutation.upsertMany.handler.ts b/packages/api/router/orgEmail/mutation.upsertMany.handler.ts index e7fb59e2009..cbeaa13c92b 100644 --- a/packages/api/router/orgEmail/mutation.upsertMany.handler.ts +++ b/packages/api/router/orgEmail/mutation.upsertMany.handler.ts @@ -1,6 +1,7 @@ import compact from 'just-compact' -import { generateNestedFreeText, getAuditedClient } from '@weareinreach/db' +import { upsertSingleKey } from '@weareinreach/crowdin/api' +import { generateId, generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' import { connectOneId, connectOrDisconnectId, @@ -21,53 +22,71 @@ const upsertMany = async ({ ctx, input }: TRPCHandlerParams { - const before = passedId ? existing.find(({ id: existingId }) => existingId === passedId) : undefined - const servicesBefore = before?.services?.map(({ serviceId }) => ({ serviceId })) ?? [] - const locationsBefore = before?.locations?.map(({ orgLocationId }) => ({ orgLocationId })) ?? [] - const id = passedId ?? ctx.generateId('orgEmail') - const services = servicesArr.map((serviceId) => ({ serviceId })) - const locations = locationsArr.map((orgLocationId) => ({ orgLocationId })) + const results: Array<{ id: string }> = [] - return prisma.orgEmail.upsert({ - where: { id }, - create: { - id, - ...record, - title: connectOneId(title), - services: createManyOptional(services), - locations: createManyOptional(locations), - description: description - ? generateNestedFreeText({ orgId, text: description, type: 'emailDesc', itemId: id }) - : undefined, - }, - update: { - id, - ...record, - title: connectOrDisconnectId(title), - services: diffConnectionsMtoN(services, servicesBefore, 'serviceId'), - locations: diffConnectionsMtoN(locations, locationsBefore, 'orgLocationId'), - description: description - ? { - upsert: { - ...generateNestedFreeText({ - orgId, - text: description, - type: 'emailDesc', - itemId: id, - }), - update: { tsKey: { update: { text: description } } }, - }, - } - : undefined, - }, + const upserts = await prisma.$transaction(async (tx) => { + for (const { + title, + services: servicesArr, + locations: locationsArr, + description, + id: passedId, + ...record + } of data) { + const before = passedId ? existing.find(({ id: existingId }) => existingId === passedId) : undefined + const servicesBefore = before?.services?.map(({ serviceId }) => ({ serviceId })) ?? [] + const locationsBefore = before?.locations?.map(({ orgLocationId }) => ({ orgLocationId })) ?? [] + const id = passedId ?? ctx.generateId('orgEmail') + + const services = servicesArr.map((serviceId) => ({ serviceId })) + const locations = locationsArr.map((orgLocationId) => ({ orgLocationId })) + + const descriptionText = description + ? generateNestedFreeTextUpsert({ + orgId, + text: description, + type: 'emailDesc', + itemId: id, + freeTextId: generateId('freeText'), + }) + : undefined + + if (descriptionText) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + key: descriptionText.upsert.create.tsKey.create.key, + text: descriptionText.upsert.create.tsKey.create.text, }) + if (crowdin.id) { + descriptionText.upsert.create.tsKey.create.crowdinId = crowdin.id + } } - ) - ) + + const txnResult = await tx.orgEmail.upsert({ + where: { id }, + create: { + id, + ...record, + title: connectOneId(title), + services: createManyOptional(services), + locations: createManyOptional(locations), + description: descriptionText?.upsert, + }, + update: { + id, + ...record, + title: connectOrDisconnectId(title), + services: diffConnectionsMtoN(services, servicesBefore, 'serviceId'), + locations: diffConnectionsMtoN(locations, locationsBefore, 'orgLocationId'), + description: descriptionText, + }, + select: { id: true }, + }) + results.push(txnResult) + } + return results + }) return upserts } export default upsertMany diff --git a/packages/api/router/orgPhone/index.ts b/packages/api/router/orgPhone/index.ts index 768b478f586..1bd2a9f06e6 100644 --- a/packages/api/router/orgPhone/index.ts +++ b/packages/api/router/orgPhone/index.ts @@ -24,15 +24,7 @@ export const orgPhoneRouter = defineRouter({ const handler = await importHandler(namespaced('get'), () => import('./query.get.handler')) return handler(opts) }), - upsertMany: permissionedProcedure('updatePhone') - .input(schema.ZUpsertManySchema) - .mutation(async (opts) => { - const handler = await importHandler( - namespaced('upsertMany'), - () => import('./mutation.upsertMany.handler') - ) - return handler(opts) - }), + forContactInfo: publicProcedure.input(schema.ZForContactInfoSchema).query(async (opts) => { const handler = await importHandler( namespaced('forContactInfo'), diff --git a/packages/api/router/orgPhone/mutation.create.handler.ts b/packages/api/router/orgPhone/mutation.create.handler.ts index e4ef57ca98d..cace2480ae7 100644 --- a/packages/api/router/orgPhone/mutation.create.handler.ts +++ b/packages/api/router/orgPhone/mutation.create.handler.ts @@ -1,3 +1,6 @@ +import invariant from 'tiny-invariant' + +import { addSingleKey } from '@weareinreach/crowdin/api' import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' @@ -5,10 +8,34 @@ import { type TCreateSchema } from './mutation.create.schema' const create = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) - const newPhone = await prisma.orgPhone.create({ - data: input, - select: { id: true }, + + const result = await prisma.$transaction(async (tx) => { + if (input.description) { + const crowdinDesc = await addSingleKey({ + isDatabaseString: true, + key: input.description.create.tsKey.create.key, + text: input.description.create.tsKey.create.text, + }) + input.description.create.tsKey.create.crowdinId = crowdinDesc.id + } + if (input.phoneType?.create) { + invariant(input.phoneType.create.key?.create) + invariant(input.phoneType.create.key.create.namespace?.connect?.name) + const crowdinPhoneType = await addSingleKey({ + isDatabaseString: false, + key: input.phoneType.create.key.create.key, + text: input.phoneType.create.key.create.text, + ns: input.phoneType.create.key.create.namespace.connect.name as 'phone-type', + }) + input.phoneType.create.key.create.crowdinId = crowdinPhoneType.id + } + + const newPhone = await tx.orgPhone.create({ + data: input, + select: { id: true }, + }) + return newPhone }) - return newPhone + return result } export default create diff --git a/packages/api/router/orgPhone/mutation.create.schema.ts b/packages/api/router/orgPhone/mutation.create.schema.ts index 907c6b42256..0885a88dbc9 100644 --- a/packages/api/router/orgPhone/mutation.create.schema.ts +++ b/packages/api/router/orgPhone/mutation.create.schema.ts @@ -24,22 +24,28 @@ export const ZCreateSchema = z const description = data.description ? generateNestedFreeText({ orgId, itemId: id, text: data.description, type: 'phoneDesc' }) : undefined - const phoneType = data.phoneTypeId - ? { connect: { id: data.phoneTypeId } } - : data.phoneTypeNew - ? { - create: { - type: data.phoneTypeNew, - key: { - create: { - key: slug(data.phoneTypeNew), - text: data.phoneTypeNew, - namespace: { connect: { name: namespace.phoneType } }, - }, + const handlePhoneType = (): Prisma.PhoneTypeCreateNestedOneWithoutAttachedPhonesInput | undefined => { + if (data.phoneTypeId) { + return { connect: { id: data.phoneTypeId } } + } + if (data.phoneTypeNew) { + return { + create: { + type: data.phoneTypeNew, + key: { + create: { + key: slug(data.phoneTypeNew), + text: data.phoneTypeNew, + namespace: { connect: { name: namespace.phoneType } }, }, }, - } - : undefined + }, + } + } + return undefined + } + + const phoneType = handlePhoneType() const { number, ext, locationOnly, primary, published } = data return Prisma.validator()({ @@ -49,9 +55,9 @@ export const ZCreateSchema = z locationOnly, primary, published, - country: { connect: { id: data.countryId } }, description, phoneType, + country: { connect: { id: data.countryId } }, }) }) export type TCreateSchema = z.infer diff --git a/packages/api/router/orgPhone/mutation.update.handler.ts b/packages/api/router/orgPhone/mutation.update.handler.ts index a437fad0866..fcb6f280e51 100644 --- a/packages/api/router/orgPhone/mutation.update.handler.ts +++ b/packages/api/router/orgPhone/mutation.update.handler.ts @@ -1,4 +1,5 @@ -import { generateFreeText, getAuditedClient } from '@weareinreach/db' +import { upsertSingleKey } from '@weareinreach/crowdin/api' +import { generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' @@ -8,35 +9,29 @@ const update = async ({ ctx, input }: TRPCHandlerParams { + if (textData) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + key: textData.upsert.create.tsKey.create.key, + text: textData.upsert.create.tsKey.create.text, + }) + textData.upsert.create.tsKey.create.crowdinId = crowdin.id + } + const updatedRecord = await tx.orgPhone.update({ + where: { id }, + data: { + ...rest, + ...(textData ? { description: textData } : description === null && { description: { delete: true } }), + ...(countryId && { country: { connect: { id: countryId } } }), + ...(phoneTypeId && { phoneType: { connect: { id: phoneTypeId } } }), + }, + }) + return updatedRecord }) - return updatedRecord + return result } export default update diff --git a/packages/api/router/orgPhone/mutation.upsertMany.handler.ts b/packages/api/router/orgPhone/mutation.upsertMany.handler.ts deleted file mode 100644 index 76935cf70ac..00000000000 --- a/packages/api/router/orgPhone/mutation.upsertMany.handler.ts +++ /dev/null @@ -1,85 +0,0 @@ -import compact from 'just-compact' - -import { generateNestedFreeText, getAuditedClient } from '@weareinreach/db' -import { - connectOneId, - connectOneIdRequired, - connectOrDisconnectId, - createManyOptional, - diffConnectionsMtoN, -} from '~api/schemas/nestedOps' -import { type TRPCHandlerParams } from '~api/types/handler' - -import { type TUpsertManySchema } from './mutation.upsertMany.schema' - -const upsertMany = async ({ ctx, input }: TRPCHandlerParams) => { - const prisma = getAuditedClient(ctx.actorId) - const { orgId, data } = input - - const existing = await prisma.orgPhone.findMany({ - where: { - id: { in: compact(data.map(({ id }) => id)) }, - }, - include: { services: true, locations: true }, - }) - const upserts = await prisma.$transaction( - data.map( - ({ - phoneType, - country, - services: servicesArr, - locations: locationsArr, - description, - id: passedId, - ...record - }) => { - const before = passedId ? existing.find(({ id: existingId }) => existingId === passedId) : undefined - const servicesBefore = before?.services?.map(({ serviceId }) => ({ serviceId })) ?? [] - const locationsBefore = before?.locations?.map(({ orgLocationId }) => ({ orgLocationId })) ?? [] - - const id = passedId ?? ctx.generateId('orgPhone') - - const services = servicesArr.map((serviceId) => ({ serviceId })) - const locations = locationsArr.map((orgLocationId) => ({ orgLocationId })) - - return prisma.orgPhone.upsert({ - where: { id }, - create: { - id, - ...record, - country: connectOneIdRequired(country.id), - phoneType: connectOneId(phoneType), - services: createManyOptional(services), - locations: createManyOptional(locations), - description: description - ? generateNestedFreeText({ orgId, text: description, type: 'phoneDesc', itemId: id }) - : undefined, - }, - update: { - id, - ...record, - country: connectOneIdRequired(country.id), - phoneType: connectOrDisconnectId(phoneType), - services: diffConnectionsMtoN(services, servicesBefore, 'serviceId'), - locations: diffConnectionsMtoN(locations, locationsBefore, 'orgLocationId'), - description: description - ? { - upsert: { - ...generateNestedFreeText({ - orgId, - text: description, - type: 'phoneDesc', - itemId: id, - }), - update: { tsKey: { update: { text: description } } }, - }, - } - : undefined, - }, - }) - } - ) - ) - return upserts -} -export default upsertMany diff --git a/packages/api/router/orgPhone/mutation.upsertMany.schema.ts b/packages/api/router/orgPhone/mutation.upsertMany.schema.ts deleted file mode 100644 index 78b0ebf25a7..00000000000 --- a/packages/api/router/orgPhone/mutation.upsertMany.schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod' - -import { prefixedId } from '~api/schemas/idPrefix' - -export const ZUpsertManySchema = z.object({ - orgId: prefixedId('organization'), - data: z - .object({ - id: z.string().optional(), - number: z.string(), - ext: z.string().nullish(), - country: z.object({ id: prefixedId('country'), cca2: z.string() }), - phoneType: prefixedId('phoneType').nullish(), - primary: z.boolean(), - published: z.boolean(), - deleted: z.boolean(), - locations: prefixedId('orgLocation').array(), - services: prefixedId('orgService').array(), - description: z.string().optional(), - }) - .array(), -}) - -export type TUpsertManySchema = z.infer diff --git a/packages/api/router/orgPhone/schemas.ts b/packages/api/router/orgPhone/schemas.ts index 351696640f6..787d2571cdb 100644 --- a/packages/api/router/orgPhone/schemas.ts +++ b/packages/api/router/orgPhone/schemas.ts @@ -2,7 +2,6 @@ export * from './mutation.create.schema' export * from './mutation.locationLink.schema' export * from './mutation.update.schema' -export * from './mutation.upsertMany.schema' export * from './query.forContactInfo.schema' export * from './query.forContactInfoEdit.schema' export * from './query.forEditDrawer.schema' diff --git a/packages/api/router/orgWebsite/mutation.create.handler.ts b/packages/api/router/orgWebsite/mutation.create.handler.ts index 890637235c7..04ac9cabe45 100644 --- a/packages/api/router/orgWebsite/mutation.create.handler.ts +++ b/packages/api/router/orgWebsite/mutation.create.handler.ts @@ -1,14 +1,44 @@ -import { getAuditedClient } from '@weareinreach/db' +import { addSingleKey } from '@weareinreach/crowdin/api' +import { generateId, generateNestedFreeText, getAuditedClient } from '@weareinreach/db' +import { connectOneId } from '~api/schemas/nestedOps' import { type TRPCHandlerParams } from '~api/types/handler' import { type TCreateSchema } from './mutation.create.schema' const create = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) - const newRecord = await prisma.orgWebsite.create({ - data: input, - select: { id: true }, + const { data, orgId } = input + const id = generateId('orgWebsite') + const description = data.description + ? generateNestedFreeText({ orgId, itemId: id, text: data.description, type: 'websiteDesc' }) + : undefined + + const { url, isPrimary, published, organizationId, orgLocationId, orgLocationOnly } = data + + const result = await prisma.$transaction(async (tx) => { + if (description) { + const crowdin = await addSingleKey({ + isDatabaseString: true, + key: description.create.tsKey.create.key, + text: description.create.tsKey.create.text, + }) + description.create.tsKey.create.crowdinId = crowdin.id + } + const newRecord = await tx.orgWebsite.create({ + data: { + id, + url, + isPrimary, + published, + orgLocationOnly, + description, + organization: connectOneId(organizationId), + locations: orgLocationId ? { create: { orgLocationId } } : undefined, + }, + select: { id: true }, + }) + return newRecord }) - return newRecord + return result } export default create diff --git a/packages/api/router/orgWebsite/mutation.create.schema.ts b/packages/api/router/orgWebsite/mutation.create.schema.ts index e2845e406f7..85b2c59e739 100644 --- a/packages/api/router/orgWebsite/mutation.create.schema.ts +++ b/packages/api/router/orgWebsite/mutation.create.schema.ts @@ -1,38 +1,18 @@ import { z } from 'zod' -import { generateId, generateNestedFreeText, Prisma } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -import { connectOneId } from '~api/schemas/nestedOps' -export const ZCreateSchema = z - .object({ - orgId: prefixedId('organization'), - data: z.object({ - url: z.string(), - isPrimary: z.boolean().optional(), - published: z.boolean().optional(), - organizationId: prefixedId('organization').optional(), - orgLocationId: prefixedId('orgLocation').optional(), - orgLocationOnly: z.boolean(), - description: z.string().optional(), - }), - }) - .transform(({ data, orgId }) => { - const id = generateId('orgWebsite') - const description = data.description - ? generateNestedFreeText({ orgId, itemId: id, text: data.description, type: 'websiteDesc' }) - : undefined +export const ZCreateSchema = z.object({ + orgId: prefixedId('organization'), + data: z.object({ + url: z.string(), + isPrimary: z.boolean().optional(), + published: z.boolean().optional(), + organizationId: prefixedId('organization').optional(), + orgLocationId: prefixedId('orgLocation').optional(), + orgLocationOnly: z.boolean(), + description: z.string().optional(), + }), +}) - const { url, isPrimary, published, organizationId, orgLocationId, orgLocationOnly } = data - return Prisma.validator()({ - id, - url, - isPrimary, - published, - orgLocationOnly, - description, - organization: connectOneId(organizationId), - locations: orgLocationId ? { create: { orgLocationId } } : undefined, - }) - }) export type TCreateSchema = z.infer diff --git a/packages/api/router/orgWebsite/mutation.update.handler.ts b/packages/api/router/orgWebsite/mutation.update.handler.ts index 61d2505b48c..2ff0e8bdd0f 100644 --- a/packages/api/router/orgWebsite/mutation.update.handler.ts +++ b/packages/api/router/orgWebsite/mutation.update.handler.ts @@ -1,6 +1,6 @@ import * as R from 'remeda' -import { getAuditedClient } from '@weareinreach/db' +import { getAuditedClient, Prisma } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' @@ -8,31 +8,51 @@ import { type TUpdateSchema } from './mutation.update.schema' const update = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) const { - where, - data, - data: { url }, + data: { orgLocationId, organizationId, url, ...data }, + id, } = input - console.log(input) - if (R.isString(url)) { - const { locations, ...rest } = data - const upserted = await prisma.orgWebsite.upsert({ - where, - create: { - id: where.id, - url, - ...(locations && { locations: { create: locations.upsert.create } }), - ...rest, - }, - update: data, - }) + const updateArgs = Prisma.validator()({ + where: { id }, + data: { + ...data, + ...(orgLocationId && { + locations: { + upsert: { + where: { + orgLocationId_orgWebsiteId: { + orgLocationId, + orgWebsiteId: id, + }, + }, + create: { orgLocationId }, + update: { orgLocationId }, + }, + }, + }), + ...(organizationId && { organization: { connect: { id: organizationId } } }), + }, + }) - return upserted - } else { - const updated = await prisma.orgWebsite.update({ - where, - data, - }) - return updated - } + const result = await prisma.$transaction(async (tx) => { + if (R.isString(url)) { + const { locations, ...rest } = updateArgs.data + const upserted = await tx.orgWebsite.upsert({ + where: updateArgs.where, + create: { + id, + url, + ...(locations && { locations: { create: locations.upsert.create } }), + ...rest, + }, + update: data, + }) + + return upserted + } else { + const updated = await tx.orgWebsite.update(updateArgs) + return updated + } + }) + return result } export default update diff --git a/packages/api/router/orgWebsite/mutation.update.schema.ts b/packages/api/router/orgWebsite/mutation.update.schema.ts index 05415210643..d8c4757bea8 100644 --- a/packages/api/router/orgWebsite/mutation.update.schema.ts +++ b/packages/api/router/orgWebsite/mutation.update.schema.ts @@ -1,44 +1,20 @@ import { z } from 'zod' -import { Prisma } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -export const ZUpdateSchema = z - .object({ - id: prefixedId('orgWebsite'), - data: z - .object({ - url: z.string(), - isPrimary: z.boolean(), - published: z.boolean(), - deleted: z.boolean(), - organizationId: prefixedId('organization').nullish().catch(undefined), - orgLocationId: prefixedId('orgLocation').optional().catch(undefined), - orgLocationOnly: z.boolean(), - }) - .partial(), - }) - .transform(({ data: { orgLocationId, organizationId, ...data }, id }) => { - return Prisma.validator()({ - where: { id }, - data: { - ...data, - ...(orgLocationId && { - locations: { - upsert: { - where: { - orgLocationId_orgWebsiteId: { - orgLocationId, - orgWebsiteId: id, - }, - }, - create: { orgLocationId }, - update: { orgLocationId }, - }, - }, - }), - ...(organizationId && { organization: { connect: { id: organizationId } } }), - }, +export const ZUpdateSchema = z.object({ + id: prefixedId('orgWebsite'), + data: z + .object({ + url: z.string(), + isPrimary: z.boolean(), + published: z.boolean(), + deleted: z.boolean(), + organizationId: prefixedId('organization').nullish().catch(undefined), + orgLocationId: prefixedId('orgLocation').optional().catch(undefined), + orgLocationOnly: z.boolean(), }) - }) + .partial(), +}) + export type TUpdateSchema = z.infer diff --git a/packages/api/router/orgWebsite/mutation.upsert.handler.ts b/packages/api/router/orgWebsite/mutation.upsert.handler.ts index e1be289d102..3cf5d4b91ca 100644 --- a/packages/api/router/orgWebsite/mutation.upsert.handler.ts +++ b/packages/api/router/orgWebsite/mutation.upsert.handler.ts @@ -1,3 +1,4 @@ +import { upsertSingleKey } from '@weareinreach/crowdin/api' import { generateId, generateNestedFreeText, @@ -23,56 +24,90 @@ const upsert = async ({ ctx, input }: TRPCHandlerParams { + const generateDescription = (): GeneratedDescription | undefined => { if (!desc || !organizationId) { return undefined } if (isCreateData(operation, data)) { - return Prisma.validator()( - generateNestedFreeText({ - orgId: organizationId, - text: desc, - type: 'websiteDesc', - itemId: id, - }) - ) + const nestedDesc = generateNestedFreeText({ + orgId: organizationId, + text: desc, + type: 'websiteDesc', + itemId: id, + }) + const crowdinArgs = { + key: nestedDesc.create.tsKey.create.key, + text: nestedDesc.create.tsKey.create.text, + } + return { + crowdinArgs, + prisma: Prisma.validator()(nestedDesc), + } } else { - return Prisma.validator()( - generateNestedFreeTextUpsert({ - orgId: organizationId, - text: desc, - type: 'websiteDesc', - itemId: id, - }) - ) + const nestedDesc = generateNestedFreeTextUpsert({ + orgId: organizationId, + text: desc, + type: 'websiteDesc', + itemId: id, + }) + const crowdinArgs = { + key: nestedDesc.upsert.create.tsKey.create.key, + text: nestedDesc.upsert.create.tsKey.create.text, + } + return { + crowdinArgs, + prisma: Prisma.validator()(nestedDesc), + } } } const description = generateDescription() - const result = isCreateData(operation, data) - ? await prisma.orgWebsite.create({ - data: { - id, - ...(description && { description }), - ...data, - locations: createOne(orgLocationId, 'orgLocationId'), - organization: connectOne(organizationId, 'id'), - }, - }) - : await prisma.orgWebsite.update({ - where: { id }, - data: { - ...(description && { description }), - ...data, - }, + const result = await prisma.$transaction(async (tx) => { + if (description) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + ...description.crowdinArgs, }) + if (description.prisma.create?.tsKey?.create) { + description.prisma.create.tsKey.create.crowdinId = crowdin.id + } + } + const txnResult = isCreateData(operation, data) + ? await tx.orgWebsite.create({ + data: { + id, + ...(description && { description: description.prisma }), + ...data, + locations: createOne(orgLocationId, 'orgLocationId'), + organization: connectOne(organizationId, 'id'), + }, + }) + : await tx.orgWebsite.update({ + where: { id }, + data: { + ...(description && { description: description.prisma }), + ...data, + }, + }) + + return txnResult + }) return result } catch (error) { return handleError(error) } } export default upsert + +type CrowdinData = { + key: string + text: string +} + +type GeneratedDescription = { + crowdinArgs: CrowdinData + prisma: + | Prisma.FreeTextCreateNestedOneWithoutOrgWebsiteInput + | Prisma.FreeTextUpdateOneWithoutOrgWebsiteNestedInput +} diff --git a/packages/api/router/organization/mutation.attachAttribute.handler.ts b/packages/api/router/organization/mutation.attachAttribute.handler.ts index 8b5f65e13b5..5b6581e1d8b 100644 --- a/packages/api/router/organization/mutation.attachAttribute.handler.ts +++ b/packages/api/router/organization/mutation.attachAttribute.handler.ts @@ -1,3 +1,4 @@ +import { addSingleKeyFromNestedFreetextCreate } from '@weareinreach/crowdin/api' import { generateNestedFreeText, getAuditedClient } from '@weareinreach/db' import { connectOneId, connectOneIdRequired } from '~api/schemas/nestedOps' import { type TRPCHandlerParams } from '~api/types/handler' @@ -22,25 +23,31 @@ const attachAttribute = async ({ ctx, input }: TRPCHandlerParams { + if (freeText) { + const { id: crowdinId } = await addSingleKeyFromNestedFreetextCreate(freeText) + freeText.create.tsKey.create.crowdinId = crowdinId + } + const result = await tx.attributeSupplement.create({ + data: { + id: input.id, + attribute: connectOneIdRequired(input.attributeId), + organization: connectOneId(organizationId), + country: connectOneId(input.countryId), + govDist: connectOneId(input.govDistId), + language: connectOneId(input.languageId), + service: connectOneId(serviceId), + location: connectOneId(locationId), + boolean: input.boolean, + data: input.data, + text: freeText, + }, + select: { + id: true, + }, + }) + return result }) - return result + return batchedUpdate } export default attachAttribute diff --git a/packages/api/router/organization/mutation.createNewQuick.handler.ts b/packages/api/router/organization/mutation.createNewQuick.handler.ts index 625629576e1..d954725f2bb 100644 --- a/packages/api/router/organization/mutation.createNewQuick.handler.ts +++ b/packages/api/router/organization/mutation.createNewQuick.handler.ts @@ -1,3 +1,4 @@ +import { addSingleKeyFromNestedFreetextCreate } from '@weareinreach/crowdin/api' import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' @@ -6,8 +7,15 @@ import { type TCreateNewQuickSchema } from './mutation.createNewQuick.schema' const createNewQuick = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) - const result = await prisma.organization.create(input) + const batchedResult = await prisma.$transaction(async (tx) => { + if (input.data.description) { + const { id: crowdinId } = await addSingleKeyFromNestedFreetextCreate(input.data.description) + input.data.description.create.tsKey.create.crowdinId = crowdinId + } + const result = await tx.organization.create(input) - return result + return result + }) + return batchedResult } export default createNewQuick diff --git a/packages/api/router/organization/mutation.createNewSuggestion.handler.ts b/packages/api/router/organization/mutation.createNewSuggestion.handler.ts index 9ddbc39a404..addc490454e 100644 --- a/packages/api/router/organization/mutation.createNewSuggestion.handler.ts +++ b/packages/api/router/organization/mutation.createNewSuggestion.handler.ts @@ -1,4 +1,4 @@ -import { getAuditedClient } from '@weareinreach/db' +import { generateId, getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TCreateNewSuggestionSchema } from './mutation.createNewSuggestion.schema' @@ -8,7 +8,32 @@ const createNewSuggestion = async ({ input, }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) - const result = await prisma.suggestion.create(input) + const { countryId, orgName, orgSlug, communityFocus, orgAddress, orgWebsite, serviceCategories } = input + const organizationId = generateId('organization') + + const result = await prisma.suggestion.create({ + data: { + organization: { + create: { + id: organizationId, + name: orgName, + slug: orgSlug, + source: { connect: { source: 'suggestion' } }, + }, + }, + data: { + orgWebsite, + orgAddress, + countryId, + communityFocus, + serviceCategories, + }, + suggestedBy: { connect: { id: ctx.actorId } }, + }, + select: { + id: true, + }, + }) return result } export default createNewSuggestion diff --git a/packages/api/router/organization/mutation.createNewSuggestion.schema.ts b/packages/api/router/organization/mutation.createNewSuggestion.schema.ts index c9a328ed191..b5959944001 100644 --- a/packages/api/router/organization/mutation.createNewSuggestion.schema.ts +++ b/packages/api/router/organization/mutation.createNewSuggestion.schema.ts @@ -1,51 +1,22 @@ import { z } from 'zod' -import { generateId, Prisma } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -export const ZCreateNewSuggestionSchema = z - .object({ - countryId: prefixedId('country'), - orgName: z.string().trim().min(2), - orgSlug: z.string().regex(/^[A-Za-z0-9-]*$/, 'Slug must only contain letters, numbers, and hyphens'), - orgWebsite: z.string().trim().url().optional(), - orgAddress: z - .object({ - street1: z.string(), - city: z.string(), - govDist: z.string(), - postCode: z.string(), - }) - .partial() - .nullish(), - serviceCategories: prefixedId('serviceCategory').array().nullish(), - communityFocus: prefixedId('attribute').array().nullish(), - }) - .transform((data) => { - const { countryId, orgName, orgSlug, communityFocus, orgAddress, orgWebsite, serviceCategories } = data - const organizationId = generateId('organization') - - return Prisma.validator()({ - data: { - organization: { - create: { - id: organizationId, - name: orgName, - slug: orgSlug, - source: { connect: { source: 'suggestion' } }, - }, - }, - data: { - orgWebsite, - orgAddress, - countryId, - communityFocus, - serviceCategories, - }, - }, - select: { - id: true, - }, +export const ZCreateNewSuggestionSchema = z.object({ + countryId: prefixedId('country'), + orgName: z.string().trim().min(2), + orgSlug: z.string().regex(/^[A-Za-z0-9-]*$/, 'Slug must only contain letters, numbers, and hyphens'), + orgWebsite: z.string().trim().url().optional(), + orgAddress: z + .object({ + street1: z.string(), + city: z.string(), + govDist: z.string(), + postCode: z.string(), }) - }) + .partial() + .nullish(), + serviceCategories: prefixedId('serviceCategory').array().nullish(), + communityFocus: prefixedId('attribute').array().nullish(), +}) export type TCreateNewSuggestionSchema = z.infer diff --git a/packages/api/router/organization/mutation.updateBasic.handler.ts b/packages/api/router/organization/mutation.updateBasic.handler.ts index 734ce66e0b7..09c5116fddc 100644 --- a/packages/api/router/organization/mutation.updateBasic.handler.ts +++ b/packages/api/router/organization/mutation.updateBasic.handler.ts @@ -1,19 +1,16 @@ -import { crowdinApi, getStringIdByKey, projectId } from '@weareinreach/crowdin/api' +import { addSingleKey, updateSingleKey } from '@weareinreach/crowdin/api' import { - generateFreeText, generateId, + generateNestedFreeTextUpsert, generateUniqueSlug, getAuditedClient, type Prisma, } from '@weareinreach/db' -import { isVercelProd } from '@weareinreach/env' -import { createLoggerInstance } from '@weareinreach/util/logger' import { handleError } from '~api/lib/errorHandler' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateBasicSchema } from './mutation.updateBasic.schema' -const logger = createLoggerInstance('api - organization.updateBasic') const updateBasic = async ({ ctx, input }: TRPCHandlerParams) => { try { const prisma = getAuditedClient(ctx.actorId) @@ -30,14 +27,36 @@ const updateBasic = async ({ ctx, input }: TRPCHandlerParams) => { const prisma = getAuditedClient(ctx.actorId) + const { orgId, data } = input + const id = generateId('orgService') + const serviceName = generateNestedFreeText({ + orgId, + text: data.serviceName, + type: 'svcName', + itemId: id, + }) + const description = data.description + ? generateNestedFreeText({ orgId, text: data.description, type: 'svcDesc', itemId: id }) + : undefined + const organization = connectOneId(data.organizationId) + const { published } = data - const result = await prisma.orgService.create(input) + const result = await prisma.$transaction(async (tx) => { + if (serviceName) { + const crowdin = await addSingleKey({ + isDatabaseString: true, + key: serviceName.create.tsKey.create.key, + text: serviceName.create.tsKey.create.text, + }) + serviceName.create.tsKey.create.crowdinId = crowdin.id + } + if (description) { + const crowdin = await addSingleKey({ + isDatabaseString: true, + key: description.create.tsKey.create.key, + text: description.create.tsKey.create.text, + }) + description.create.tsKey.create.crowdinId = crowdin.id + } + const createData = { + id, + serviceName, + description, + organization, + published, + } + + const newService = await tx.orgService.create({ data: createData }) + return newService + }) return result } export default create diff --git a/packages/api/router/service/mutation.create.schema.ts b/packages/api/router/service/mutation.create.schema.ts index 7fba0089eb8..e7ffb5d3941 100644 --- a/packages/api/router/service/mutation.create.schema.ts +++ b/packages/api/router/service/mutation.create.schema.ts @@ -1,42 +1,13 @@ import { z } from 'zod' -import { generateId, generateNestedFreeText, Prisma } from '@weareinreach/db' -import { connectOneId } from '~api/schemas/nestedOps' - -export const ZCreateSchema = z - .object({ - orgId: z.string(), - data: z.object({ - serviceName: z.string(), - description: z.string().optional(), - organizationId: z.string(), - published: z.boolean().optional(), - }), - }) - .transform((parsedData) => { - const { orgId, data } = parsedData - const id = generateId('orgService') - const serviceName = generateNestedFreeText({ - orgId, - text: data.serviceName, - type: 'svcName', - itemId: id, - }) - const description = data.description - ? generateNestedFreeText({ orgId, text: data.description, type: 'svcDesc', itemId: id }) - : undefined - const organization = connectOneId(data.organizationId) - const { published } = data - - const recordData = { - id, - serviceName, - description, - organization, - published, - } - - return Prisma.validator()({ data: recordData }) - }) +export const ZCreateSchema = z.object({ + orgId: z.string(), + data: z.object({ + serviceName: z.string(), + description: z.string().optional(), + organizationId: z.string(), + published: z.boolean().optional(), + }), +}) export type TCreateSchema = z.infer diff --git a/packages/api/router/service/mutation.createAccessInstructions.handler.ts b/packages/api/router/service/mutation.createAccessInstructions.handler.ts index dff25497c0c..2eec70edf0b 100644 --- a/packages/api/router/service/mutation.createAccessInstructions.handler.ts +++ b/packages/api/router/service/mutation.createAccessInstructions.handler.ts @@ -1,3 +1,4 @@ +import { addSingleKey } from '@weareinreach/crowdin/api' import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' @@ -11,6 +12,14 @@ const createAccessInstructions = async ({ const { attributeSupplement, freeText, translationKey } = input const result = await prisma.$transaction(async (tx) => { + if (translationKey) { + const crowdin = await addSingleKey({ + isDatabaseString: true, + key: translationKey.data.key, + text: translationKey.data.text, + }) + translationKey.data.crowdinId = crowdin.id + } const tKey = translationKey ? await tx.translationKey.create(translationKey) : undefined const fText = freeText ? await tx.freeText.create(freeText) : undefined const aSupp = attributeSupplement ? await tx.attributeSupplement.create(attributeSupplement) : undefined diff --git a/packages/api/router/service/mutation.upsert.handler.ts b/packages/api/router/service/mutation.upsert.handler.ts index 9aa3b222f65..ef3aff8680c 100644 --- a/packages/api/router/service/mutation.upsert.handler.ts +++ b/packages/api/router/service/mutation.upsert.handler.ts @@ -1,4 +1,5 @@ -import { generateNestedFreeText, generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' +import { upsertSingleKey } from '@weareinreach/crowdin/api' +import { generateNestedFreeTextUpsert, getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpsertSchema } from './mutation.upsert.schema' @@ -13,72 +14,82 @@ const upsert = async ({ ctx, input }: TRPCHandlerParams { + if (serviceName) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + key: serviceName.upsert.create.tsKey.create.key, + text: serviceName.upsert.create.tsKey.create.text, + }) + serviceName.upsert.create.tsKey.create.crowdinId = crowdin.id + } + if (description) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + key: description.upsert.create.tsKey.create.key, + text: description.upsert.create.tsKey.create.text, + }) + description.upsert.create.tsKey.create.crowdinId = crowdin.id + } - const result = await prisma.orgService.upsert({ - where: { - id, - }, - create: { - id, - deleted, - published, - ...(input.services?.createdVals && { - services: { - createMany: { data: input.services.createdVals.map((tagId) => ({ tagId })), skipDuplicates: true }, - }, - }), - ...(input.name && { - serviceName: generateNestedFreeText({ - orgId, - itemId: id, - type: 'svcName', - text: input.name, - }), - }), - ...(input.description && { - description: generateNestedFreeText({ - orgId, - itemId: id, - type: 'svcDesc', - text: input.description, - }), - }), - }, - update: { - published, - deleted, - ...(hasServiceUpdates && { - services: { - ...(input.services?.deletedVals && { - deleteMany: { tagId: { in: input.services.deletedVals } }, - }), - ...(input.services?.createdVals && { + const upsertedRecord = await tx.orgService.upsert({ + where: { + id, + }, + create: { + id, + deleted, + published, + ...(input.services?.createdVals && { + services: { createMany: { data: input.services.createdVals.map((tagId) => ({ tagId })), skipDuplicates: true, }, - }), - }, - }), - ...(input.name && { - serviceName: generateNestedFreeTextUpsert({ - orgId, - itemId: id, - type: 'svcName', - text: input.name, + }, }), - }), - ...(input.description && { - description: generateNestedFreeTextUpsert({ - orgId, - itemId: id, - type: 'svcDesc', - text: input.description, + ...(serviceName && { serviceName: { create: serviceName.upsert.create } }), + ...(description && { description: { create: description.upsert.create } }), + }, + update: { + published, + deleted, + ...(hasServiceUpdates && { + services: { + ...(input.services?.deletedVals && { + deleteMany: { tagId: { in: input.services.deletedVals } }, + }), + ...(input.services?.createdVals && { + createMany: { + data: input.services.createdVals.map((tagId) => ({ tagId })), + skipDuplicates: true, + }, + }), + }, }), - }), - }, - }) + ...(serviceName && { serviceName }), + ...(description && { description }), + }, + }) + return upsertedRecord + }) return result } export default upsert diff --git a/packages/crowdin/api/index.ts b/packages/crowdin/api/index.ts index 895abf4500b..9f356ce76a2 100644 --- a/packages/crowdin/api/index.ts +++ b/packages/crowdin/api/index.ts @@ -1,5 +1,4 @@ /* eslint-disable node/no-process-env */ -/* eslint-disable no-var */ import Crowdin from '@crowdin/crowdin-api-client' import { createCommonFns } from '../common/apiFns' @@ -17,7 +16,46 @@ if (process.env.NODE_ENV !== 'production') { global.crowdinApi = crowdinApi } declare global { + // eslint-disable-next-line no-var var crowdinApi: Crowdin | undefined } -export const { getStringIdByKey } = createCommonFns(crowdinApi) +export const { + addSingleKey, + getStringIdByKey, + updateMultipleKeys, + updateSingleKey, + addMultipleKeys, + upsertSingleKey, +} = createCommonFns(crowdinApi) + +export const addSingleKeyFromNestedFreetextCreate = async ( + freeText: AddStringFromNestedFreetextCreateParams +) => { + if (freeText.create.tsKey?.create) { + return await addSingleKey({ + isDatabaseString: true, + key: freeText.create.tsKey.create.key, + text: freeText.create.tsKey.create.text, + }) + } + throw new Error('Unable to add string to Crowdin, check args.') +} + export { branches, sourceFiles, projectId } from '../constants' + +interface AddStringFromNestedFreetextCreateParams { + create: { + id: string + tsKey?: { + create?: { + key: string + text: string + namespace: { + connect: { + name: string + } + } + } + } + } +} diff --git a/packages/crowdin/cache/index.ts b/packages/crowdin/cache/index.ts index 19349568137..e4c535d1b9d 100644 --- a/packages/crowdin/cache/index.ts +++ b/packages/crowdin/cache/index.ts @@ -6,12 +6,27 @@ import formatBytes from 'pretty-bytes' import { createLoggerInstance } from '@weareinreach/util/logger' -import { cacheTime } from '../constants' +import { cacheTime, sourceFiles } from '../constants' const log = createLoggerInstance('Vercel KV') const tracer = trace.getTracer('inreach-app') -export const redisReadCache = async (namespaces: string[], lang: string, otaManifestTimestamp: number) => { +const fileBasedNsList = Object.keys(sourceFiles('en')) + +const getManifestTimestamp = (ns: string, otaManifestTimestamps: OtaManifestTimestamps) => { + if (fileBasedNsList.includes(ns)) { + return otaManifestTimestamps.common + } + return otaManifestTimestamps.database +} + +type OtaManifestTimestamps = { common: number; database: number } + +export const redisReadCache = async ( + namespaces: string[], + lang: string, + otaManifestTimestamps: OtaManifestTimestamps +) => { const span = tracer.startSpan('redisReadCache', undefined, context.active()) try { if ((await redis.ping()) !== 'PONG') { @@ -23,6 +38,7 @@ export const redisReadCache = async (namespaces: string[], lang: string, otaMani const expireQueue: string[] = [] for (const ns of namespaces) { + const manifestTimestamp = getManifestTimestamp(ns, otaManifestTimestamps) const cacheKey = `${ns}[${lang}]` const itemTTL = await redis.ttl(cacheKey) @@ -32,7 +48,7 @@ export const redisReadCache = async (namespaces: string[], lang: string, otaMani } const expiretime = itemTTL + Math.round(Date.now() / 1000) - if (otaManifestTimestamp > expiretime - cacheTime) { + if (manifestTimestamp > expiretime - cacheTime) { log.info(`Manifest is newer than cache - skipping cache for ${cacheKey}`) continue } @@ -75,10 +91,11 @@ export const redisWriteCache = async (data: WriteCacheArgs[]) => { const span = tracer.startSpan('redisWriteCache', undefined, context.active()) try { if ((await redis.ping()) !== 'PONG') { - log.warn('Skipping cache write - Redis client not connected') - return + throw new Error('Redis client not connected, skipping cache write') + } + if (!data.length) { + throw new Error('No data to write') } - if (!data.length) return const cacheKey = (ns: string, lang: string) => `${ns}[${lang}]` const pipeline = redis.pipeline() let dataSize = 0 @@ -123,9 +140,12 @@ export const redisWriteCache = async (data: WriteCacheArgs[]) => { log.info(`Total written to cache: ${writtenTotal}`) return writtenTotal + } catch (error) { + log.error(error) } finally { span.end() } + return 0 } interface WriteCacheArgs { ns: string diff --git a/packages/crowdin/common/apiFns.ts b/packages/crowdin/common/apiFns.ts index dc06363d96a..c95afbcf57b 100644 --- a/packages/crowdin/common/apiFns.ts +++ b/packages/crowdin/common/apiFns.ts @@ -1,15 +1,213 @@ -import { branches, projectId } from '../constants' +import { type PatchRequest, type ResponseObject, type SourceStringsModel } from '@crowdin/crowdin-api-client' +import invariant from 'tiny-invariant' + +import { branches, fileIds, projectId } from '../constants' import type CrowdinApi from '@crowdin/crowdin-api-client' -export const createCommonFns = (client: CrowdinApi) => ({ - getStringIdByKey: async (key: string, databaseString?: boolean) => { - const { data: crowdinString } = await client.sourceStringsApi.listProjectStrings(projectId, { - branchId: databaseString ? branches.database : branches.main, - filter: key, - scope: 'identifier', - }) +const getProjectId = (isDatabaseString: boolean = false) => + isDatabaseString ? projectId.dbContent : projectId.base + +export const createCommonFns = (client: CrowdinApi) => { + const getStringIdByKey = async (key: string, isDatabaseString?: boolean) => { + const { data: crowdinString } = await client.sourceStringsApi.listProjectStrings( + getProjectId(isDatabaseString), + { + branchId: isDatabaseString ? branches.database : branches.main, + filter: key, + scope: 'identifier', + } + ) return crowdinString.find(({ data }) => data.identifier === key)?.data.id - }, -}) + } + + const updateSingleKey: UpdateSingleString = async ({ updatedString, isDatabaseString, ...params }) => { + const stringId = params.crowdinId ?? (await getStringIdByKey(params.key, isDatabaseString)) + invariant(stringId) + const { data: response } = await client.sourceStringsApi.editString( + getProjectId(isDatabaseString), + stringId, + [{ op: 'replace', path: '/text', value: updatedString }] + ) + return response + } + const updateMultipleKeys: UpdateMultipleStrings = async (updates) => { + const baseRequest: PatchRequest[] = [] + const dbRequest: PatchRequest[] = [] + for (const { updatedString, isDatabaseString, ...params } of updates) { + const stringId = params.crowdinId ?? (await getStringIdByKey(params.key, isDatabaseString)) + invariant(stringId) + const requestArgs: PatchRequest = { + op: 'replace', + path: `${stringId}/text`, + value: updatedString, + } + + isDatabaseString ? dbRequest.push(requestArgs) : baseRequest.push(requestArgs) + } + const response: Array> = [] + + if (baseRequest.length) { + const { data: baseResult } = await client.sourceStringsApi.stringBatchOperations( + getProjectId(false), + baseRequest + ) + response.push(...baseResult) + } + if (dbRequest.length) { + const { data: dbResult } = await client.sourceStringsApi.stringBatchOperations( + getProjectId(true), + dbRequest + ) + response.push(...dbResult) + } + return response + } + const addSingleKey: AddSingleKey = async ({ isDatabaseString, key, text, ...params }) => { + const branchId = isDatabaseString ? branches.database : undefined + const fileId = isDatabaseString ? undefined : fileIds.main[params.ns ?? 'common'] + const identifier = key + + const requestArgs: typeof isDatabaseString extends true + ? SourceStringsModel.CreateStringStringsBasedRequest + : SourceStringsModel.CreateStringRequest = { + ...(branchId && { branchId }), + ...(fileId && { fileId }), + identifier, + text, + } + console.log(getProjectId(isDatabaseString), requestArgs) + + const { data: response } = await client.sourceStringsApi.addString( + getProjectId(isDatabaseString), + requestArgs + ) + + console.log(response) + return response + } + + const addMultipleKeys: AddMultipleKeys = async (newStrings) => { + const baseRequest: Array = [] + const dbRequest: Array = [] + + for (const { isDatabaseString, key: identifier, ns, text } of newStrings) { + const branchId = isDatabaseString ? branches.database : undefined + const fileId = isDatabaseString ? undefined : fileIds.main[ns ?? 'common'] + const addArgs: PatchRequest = { + op: 'add', + path: '/-', + value: { + branchId, + fileId, + identifier, + text, + }, + } + isDatabaseString ? dbRequest.push(addArgs) : baseRequest.push(addArgs) + } + const response: Array> = [] + + if (baseRequest.length) { + const { data: baseResponse } = await client.sourceStringsApi.stringBatchOperations( + getProjectId(false), + baseRequest + ) + response.push(...baseResponse) + } + if (dbRequest.length) { + const { data: dbResponse } = await client.sourceStringsApi.stringBatchOperations( + getProjectId(true), + dbRequest + ) + response.push(...dbResponse) + } + + return response + } + + const upsertSingleKey: UpsertSingleKey = async (params) => { + const { isDatabaseString, key, text } = params + const existingId = await getStringIdByKey(key, isDatabaseString) + + if (existingId) { + return await updateSingleKey({ crowdinId: existingId, updatedString: text, isDatabaseString }) + } + if (isDatabaseString) { + return await addSingleKey(params) + } + return await addSingleKey(params) + } + + return { + getStringIdByKey, + addMultipleKeys, + addSingleKey, + updateMultipleKeys, + updateSingleKey, + upsertSingleKey, + } +} + +interface UpdateStringById { + isDatabaseString: boolean + crowdinId: number + updatedString: string + key?: never +} +interface UpdateStringByKey { + isDatabaseString: boolean + crowdinId?: never + updatedString: string + key: string +} + +interface UpdateSingleString { + ({ key, updatedString, isDatabaseString }: UpdateStringByKey): Promise + ({ crowdinId, updatedString }: UpdateStringById): Promise +} +interface UpdateMultipleStrings { + (updates: Array): Promise>> + (updates: Array): Promise>> +} + +interface AddSingleKey { + (params: AddDatabaseStringParams): Promise + (params: AddFileStringParams): Promise +} +interface AddMultipleKeys { + (params: Array): Promise>> + (params: Array): Promise>> +} + +interface AddDatabaseStringParams { + isDatabaseString: true + ns?: never + key: string + text: string +} +interface AddFileStringParams { + isDatabaseString: false + ns: keyof (typeof fileIds)['main'] + key: string + text: string +} + +interface UpsertSingleKey { + (params: UpsertDatabaseString): Promise + (params: UpsertFileString): Promise +} + +interface UpsertDatabaseString { + isDatabaseString: true + ns?: never + text: string + key: string +} +interface UpsertFileString { + isDatabaseString: false + ns: keyof (typeof fileIds)['main'] + text: string + key: string +} diff --git a/packages/crowdin/common/otaFns.ts b/packages/crowdin/common/otaFns.ts index 6652025635f..a02b8ece3a0 100644 --- a/packages/crowdin/common/otaFns.ts +++ b/packages/crowdin/common/otaFns.ts @@ -1,12 +1,15 @@ import type OtaClient from '@crowdin/ota-client' -export const createCommonFns = (client: OtaClient) => ({ +export const createCommonFns = ({ common, database }: { common: OtaClient; database: OtaClient }) => ({ fetchCrowdinFile: async (file: string, lang: string) => { - client.setCurrentLocale(lang) - return client.getFileTranslations(file) + common.setCurrentLocale(lang) + return common.getFileTranslations(file) }, - fetchCrowdinDbKey: async (ns: string, file: string, lang: string) => ({ - [ns]: await client.getStringByKey(ns, lang), + fetchCrowdinDbKey: async (ns: string, lang: string) => ({ + [ns]: await database.getStringByKey(ns, lang), + }), + crowdinDistTimestamp: async () => ({ + common: await common.getManifestTimestamp(), + database: await database.getManifestTimestamp(), }), - crowdinDistTimestamp: async () => client.getManifestTimestamp(), }) diff --git a/packages/crowdin/common/updateOpts.ts b/packages/crowdin/common/updateOpts.ts deleted file mode 100644 index 033ca9fda71..00000000000 --- a/packages/crowdin/common/updateOpts.ts +++ /dev/null @@ -1,50 +0,0 @@ -import prettier from 'prettier' - -import { writeFileSync } from 'fs' -import path from 'path' - -import { crowdinApi, projectId } from '../api' - -const writeOutput = async (filename: string, data: string, isJs = false) => { - const prettierOpts = (await prettier.resolveConfig(__dirname)) ?? undefined - const parser = isJs ? 'babel' : 'typescript' - const outFile = `${path.resolve(__dirname, './')}/${filename}.${isJs ? 'mjs' : 'ts'}` - - const formattedOutput = await prettier.format(data, { ...prettierOpts, parser }) - writeFileSync(outFile, formattedOutput) -} - -const updateOpts = async () => { - const { data: branches } = await crowdinApi.sourceFilesApi.listProjectBranches(projectId) - const branchesToExport = ['main', 'dev', 'database', 'database-draft'] - - const branchMap = new Map() - for (const { data: branch } of branches) { - if (!branchesToExport.includes(branch.name)) continue - branchMap.set(branch.name, branch.id) - } - - const branchObj = Object.fromEntries(branchMap.entries()) - - const fileMap = new Map>() - - for (const [key, value] of Object.entries(branchObj)) { - if (!branchesToExport.includes(key)) continue - const { data: files } = await crowdinApi.sourceFilesApi.listProjectFiles(projectId, { - branchId: value, - }) - fileMap.set(key, Object.fromEntries(files.map(({ data }) => [data.name.replace('.json', ''), data.id]))) - } - - const fileObj = Object.fromEntries(fileMap.entries()) - - const output = ` - export const branches = ${JSON.stringify(branchObj)} as const - \n - export const files = ${JSON.stringify(fileObj)} as const - ` - - writeOutput('opts', output) -} - -updateOpts() diff --git a/packages/crowdin/constants.ts b/packages/crowdin/constants.ts index 8a9eaca6459..74a5c728865 100644 --- a/packages/crowdin/constants.ts +++ b/packages/crowdin/constants.ts @@ -1,56 +1,130 @@ -export const otaHash = 'e-39328dacf5f98928e8273b35wj' -export const otaManifest = `https://distributions.crowdin.net/${otaHash}/manifest.json` -export const projectId = 12 +import { isVercelProd } from '@weareinreach/env' +import { createLoggerInstance } from '@weareinreach/util/logger' + +const logger = createLoggerInstance('📦 Crowdin Client') +const getValue = (production: T, development: T): T => { + // eslint-disable-next-line node/no-process-env + if (isVercelProd && !process.env.CROWDIN_SANDBOX) { + logger.info('Using production environment') + return production + } + logger.info('Using development environment') + return development +} + +export const otaCommonHash = 'e-39328dacf5f98928e8273b35wj' +export const otaDbHash = 'e-c467df906f1bfdb378f23b35wj' +export const otaManifest = `https://distributions.crowdin.net/${otaCommonHash}/manifest.json` +export const getOtaManifest = (content: 'common' | 'database') => + `https://distributions.crowdin.net/${content === 'common' ? otaCommonHash : otaDbHash}/manifest.json` +export const projectId = { + base: getValue(12, 20), + dbContent: getValue(24, 24), +} // TODO: [IN-924] Create generator to update Crowdin data on build -export const sourceFiles = (lang: string) => ({ - databaseStrings: `/content/${lang}/database/org-data.json`, - attribute: `/content/main/apps/app/public/locales/${lang}/attribute.json`, - common: `/content/main/apps/app/public/locales/${lang}/common.json`, - country: `/content/main/apps/app/public/locales/${lang}/country.json`, - 'gov-dist': `/content/main/apps/app/public/locales/${lang}/gov-dist.json`, - landingPage: `/content/main/apps/app/public/locales/${lang}/landingPage.json`, - 'phone-type': `/content/main/apps/app/public/locales/${lang}/phone-type.json`, - services: `/content/main/apps/app/public/locales/${lang}/services.json`, - suggestOrg: `/content/main/apps/app/public/locales/${lang}/suggestOrg.json`, - 'user-title': `/content/main/apps/app/public/locales/${lang}/user-title.json`, - user: `/content/main/apps/app/public/locales/${lang}/user.json`, -}) +export const sourceFiles = (lang: string) => + getValue( + { + // databaseStrings: `/content/${lang}/database/org-data.json`, + attribute: `/content/main/apps/app/public/locales/${lang}/attribute.json`, + common: `/content/main/apps/app/public/locales/${lang}/common.json`, + country: `/content/main/apps/app/public/locales/${lang}/country.json`, + 'gov-dist': `/content/main/apps/app/public/locales/${lang}/gov-dist.json`, + landingPage: `/content/main/apps/app/public/locales/${lang}/landingPage.json`, + 'phone-type': `/content/main/apps/app/public/locales/${lang}/phone-type.json`, + services: `/content/main/apps/app/public/locales/${lang}/services.json`, + suggestOrg: `/content/main/apps/app/public/locales/${lang}/suggestOrg.json`, + 'user-title': `/content/main/apps/app/public/locales/${lang}/user-title.json`, + user: `/content/main/apps/app/public/locales/${lang}/user.json`, + } as const, + { + // databaseStrings: `/content/${lang}/database/org-data.json`, + attribute: `/content/main/${lang}/attribute.json`, + common: `/content/main/${lang}/common.json`, + country: `/content/main/${lang}/country.json`, + 'gov-dist': `/content/main/${lang}/gov-dist.json`, + landingPage: `/content/main/${lang}/landingPage.json`, + 'phone-type': `/content/main/${lang}/phone-type.json`, + services: `/content/main/${lang}/services.json`, + suggestOrg: `/content/main/${lang}/suggestOrg.json`, + 'user-title': `/content/main/${lang}/user-title.json`, + user: `/content/main/${lang}/user.json`, + } as const + ) -export const branches = { - main: 3539, - dev: 32, - database: 790, - 'database-draft': 792, -} -export const fileIds = { - dev: { - common: 46, - 'gov-dist': 1338, - 'phone-type': 1340, - country: 1344, - services: 1348, - attribute: 1350, - landingPage: 1352, - user: 1356, - suggestOrg: 1450, - 'user-title': 1781, - }, - database: { 'org-data': 794 }, - 'database-draft': {}, - main: { - attribute: 3543, - common: 3541, - country: 3545, - 'gov-dist': 3547, - landingPage: 3549, - 'phone-type': 3551, - services: 3553, - suggestOrg: 3555, - 'user-title': 3557, - user: 3559, - }, -} as const +export const branches = getValue( + { + main: 3539, + dev: 32, + database: 790, + 'database-draft': 792, + } as const, + { + main: 5354, + dev: 5360, + database: 5412, + 'database-draft': 5416, + } as const +) +export const fileIds = getValue( + { + dev: { + common: 46, + 'gov-dist': 1338, + 'phone-type': 1340, + country: 1344, + services: 1348, + attribute: 1350, + landingPage: 1352, + user: 1356, + suggestOrg: 1450, + 'user-title': 1781, + }, + database: { 'org-data': 794 }, + 'database-draft': {}, + main: { + attribute: 3543, + common: 3541, + country: 3545, + 'gov-dist': 3547, + landingPage: 3549, + 'phone-type': 3551, + services: 3553, + suggestOrg: 3555, + 'user-title': 3557, + user: 3559, + }, + } as const, + { + dev: { + common: 5366, + 'gov-dist': 5362, + 'phone-type': 5372, + country: 5368, + services: 5374, + attribute: 5364, + landingPage: 5370, + user: 5380, + suggestOrg: 5376, + 'user-title': 5378, + }, + database: { 'org-data': 5346 }, + 'database-draft': {}, + main: { + attribute: 5332, + common: 5348, + country: 5336, + 'gov-dist': 5340, + landingPage: 5322, + 'phone-type': 5338, + services: 5342, + suggestOrg: 5324, + 'user-title': 5334, + user: 5344, + }, + } as const +) export const cacheTime = 86400 diff --git a/packages/crowdin/ota/edge.ts b/packages/crowdin/ota/edge.ts index 4c9a994795f..88e58861166 100644 --- a/packages/crowdin/ota/edge.ts +++ b/packages/crowdin/ota/edge.ts @@ -1,28 +1,32 @@ -/* eslint-disable no-var */ /* eslint-disable node/no-process-env */ import OtaClient from '@crowdin/ota-client' +import { type ClientConfig } from '@crowdin/ota-client/out/model' import { createCommonFns } from '../common/otaFns' -import { otaHash } from '../constants' +import { otaCommonHash, otaDbHash } from '../constants' -export const crowdinEdgeOta = - global.crowdinEdgeOta || - new OtaClient(otaHash, { - enterpriseOrganizationDomain: 'inreach', - disableJsonDeepMerge: true, - httpClient: { - get: async (url: string) => { - const data = await fetch(url) - return (await data.json()) as T - }, +const clientConfig: ClientConfig = { + enterpriseOrganizationDomain: 'inreach', + disableJsonDeepMerge: true, + httpClient: { + get: async (url: string) => { + const data = await fetch(url) + return (await data.json()) as T }, - }) + }, +} + +export const crowdinEdgeOta = global.crowdinEdgeOta || { + common: new OtaClient(otaCommonHash, clientConfig), + database: new OtaClient(otaDbHash, clientConfig), +} if (process.env.NODE_ENV !== 'production') { global.crowdinEdgeOta = crowdinEdgeOta } declare global { - var crowdinEdgeOta: OtaClient | undefined + // eslint-disable-next-line no-var + var crowdinEdgeOta: { common: OtaClient; database: OtaClient } | undefined } const { crowdinDistTimestamp, fetchCrowdinDbKey, fetchCrowdinFile } = createCommonFns(crowdinEdgeOta) diff --git a/packages/crowdin/ota/index.ts b/packages/crowdin/ota/index.ts index 6f842e88b2e..8b12eb8848b 100644 --- a/packages/crowdin/ota/index.ts +++ b/packages/crowdin/ota/index.ts @@ -1,32 +1,36 @@ -/* eslint-disable no-var */ /* eslint-disable node/no-process-env */ import OtaClient from '@crowdin/ota-client' +import { type ClientConfig } from '@crowdin/ota-client/out/model' import { createCommonFns } from '../common/otaFns' -import { otaHash } from '../constants' +import { otaCommonHash, otaDbHash } from '../constants' -export const crowdinOta = - global.crowdinOta || - new OtaClient(otaHash, { - enterpriseOrganizationDomain: 'inreach', - disableJsonDeepMerge: true, - ...(fetch instanceof Function - ? { - httpClient: { - get: async (url: string) => { - const data = await fetch(url) - return (await data.json()) as T - }, +const clientConfig: ClientConfig = { + enterpriseOrganizationDomain: 'inreach', + disableJsonDeepMerge: true, + ...(fetch instanceof Function + ? { + httpClient: { + get: async (url: string) => { + const data = await fetch(url) + return (await data.json()) as T }, - } - : {}), - }) + }, + } + : {}), +} + +export const crowdinOta = global.crowdinOta || { + common: new OtaClient(otaCommonHash, clientConfig), + database: new OtaClient(otaDbHash, clientConfig), +} if (process.env.NODE_ENV !== 'production') { global.crowdinOta = crowdinOta } declare global { - var crowdinOta: OtaClient | undefined + // eslint-disable-next-line no-var + var crowdinOta: { common: OtaClient; database: OtaClient } | undefined } const { crowdinDistTimestamp, fetchCrowdinDbKey, fetchCrowdinFile } = createCommonFns(crowdinOta) diff --git a/packages/crowdin/package.json b/packages/crowdin/package.json index 7953b1e5bf2..8879e7293c6 100644 --- a/packages/crowdin/package.json +++ b/packages/crowdin/package.json @@ -43,10 +43,12 @@ "@crowdin/ota-client": "1.0.0", "@opentelemetry/api": "1.8.0", "@vercel/kv": "1.0.1", + "@weareinreach/env": "workspace:*", "@weareinreach/util": "workspace:*", "flat": "6.0.1", "object-sizeof": "2.6.4", - "pretty-bytes": "6.1.1" + "pretty-bytes": "6.1.1", + "tiny-invariant": "1.3.3" }, "devDependencies": { "@weareinreach/config": "workspace:*", diff --git a/packages/db/lib/generateFreeText.ts b/packages/db/lib/generateFreeText.ts index 1f043ecbf99..8c834c8bf02 100644 --- a/packages/db/lib/generateFreeText.ts +++ b/packages/db/lib/generateFreeText.ts @@ -15,7 +15,7 @@ export const generateFreeText = ({ text, type, freeTextId, -}: GenerateFreeTextParams) => { +}: GenerateFreeTextParams): GenerateFreeTextReturn => { const key = (() => { switch (type) { case 'orgDesc': { @@ -44,7 +44,11 @@ export const generateFreeText = ({ const ns = namespaces.orgData invariant(key, 'Error creating key') return { - translationKey: Prisma.validator()({ key, text, ns }), + translationKey: Prisma.validator()({ + key, + text, + ns, + }), freeText: Prisma.validator()({ key, ns, @@ -52,7 +56,23 @@ export const generateFreeText = ({ }), } } -export const generateNestedFreeText = (args: GenerateFreeTextParams) => { + +interface GenerateFreeTextReturn { + translationKey: { + key: string + text: string + ns: string + crowdinId?: number + } + freeText: { + key: string + ns: string + id: string + } +} +export const generateNestedFreeText = ( + args: GenerateFreeTextParams +): NestedCreateOne => { const { freeText, translationKey } = generateFreeText(args) return { create: { @@ -68,13 +88,30 @@ export const generateNestedFreeText = (args: Gen } } +interface NestedCreateOne { + create: { + id: string + tsKey: { + create: { + key: string + text: string + crowdinId?: number + namespace: { + connect: { + name: string + } + } + } + } + } +} + export const generateNestedFreeTextUpsert = ( args: GenerateFreeTextParams -) => { +): GenerateNestedFreeTextUpsertResult => { const { freeText, translationKey } = generateFreeText(args) return { upsert: { - // where: { id: freeText.id }, create: { id: freeText.id, tsKey: { @@ -87,19 +124,28 @@ export const generateNestedFreeTextUpsert = ( }, update: { tsKey: { - // upsert: { - // create: { - // key: translationKey.key, - // text: translationKey.text, - // namespace: { connect: { name: translationKey.ns } }, - // }, update: { text: translationKey.text }, - // }, }, }, }, } } +interface GenerateNestedFreeTextUpsertResult { + upsert: { + create: { + id: string + tsKey: { + create: { + key: string + text: string + crowdinId?: number + namespace: { connect: { name: string } } + } + } + } + update: { tsKey: { update: { text: string } } } + } +} type GenerateFreeTextParams = GenerateFreeTextWithItem interface GenerateFreeTextBase { diff --git a/packages/db/package.json b/packages/db/package.json index 0ede0ff0840..b158e77a964 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -55,6 +55,7 @@ "id128": "1.6.6", "json-schema-to-zod": "2.0.14", "kysely": "0.27.3", + "ms": "2.1.3", "pg": "8.11.5", "prisma-kysely": "1.8.0", "sql-bricks": "3.0.1", @@ -72,6 +73,7 @@ "@types/inquirer": "9.0.7", "@types/inquirer-autocomplete-prompt": "3.0.3", "@types/luxon": "3.4.2", + "@types/ms": "0.7.34", "@types/node": "20.12.7", "@types/papaparse": "5.3.14", "@types/pg": "8.11.5", @@ -81,6 +83,7 @@ "dotenv": "16.4.5", "dotenv-cli": "7.4.1", "eslint": "8.57.0", + "flat": "6.0.1", "google-auth-library": "9.9.0", "google-spreadsheet": "4.1.1", "googleapis": "134.0.0", diff --git a/packages/db/prisma/common.ts b/packages/db/prisma/common.ts index 5e675e2bdd3..cfe2a7d5e10 100644 --- a/packages/db/prisma/common.ts +++ b/packages/db/prisma/common.ts @@ -22,7 +22,13 @@ export const raise = (err: string): never => { export const downloadFromDatastore = async (path: string, logger?: FormatMessage): Promise => { // eslint-disable-next-line node/no-process-env - const gh = new Octokit({ auth: process.env.GH_DATASTORE_PAT }) + const githubPAT = process.env.GH_DATASTORE_PAT + if (!githubPAT) { + throw new Error( + `Missing 'GH_DATASTORE_PAT' environment variable.\nIf you need to generate a new one, visit https://github.com/settings/tokens\nThe token must be CLASSIC and not the newer 'fine-grained' variety. When selecting the scopes, the minimum required is 'repo'` + ) + } + const gh = new Octokit({ auth: githubPAT }) const log = logger || console.log const datafileInfo = await gh.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: 'weareinreach', @@ -36,4 +42,5 @@ export const downloadFromDatastore = async (path: string, logger?: FormatMessage log(`Downloaded '${datafileInfo.data.path}' (${prettyBytes(size)})`) return data } + throw new Error('Unable to download from datastore') } diff --git a/packages/db/prisma/data-migrations/2024-04-24_update-crowdin-ids.ts b/packages/db/prisma/data-migrations/2024-04-24_update-crowdin-ids.ts new file mode 100644 index 00000000000..99af500bb7d --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-04-24_update-crowdin-ids.ts @@ -0,0 +1,82 @@ +import ms from 'ms' +import { z } from 'zod' + +import { type MigrationJob } from '~db/prisma/dataMigrationRunner' +import { type JobDef } from '~db/prisma/jobPreRun' + +const DataSchema = z + .object({ + key: z.string(), + ns: z.string(), + crowdinId: z.number(), + }) + .array() + +/** Define the job metadata here. */ +const jobDef: JobDef = { + jobId: '2024-04-24_update-crowdin-ids', + title: 'update crowdin ids', + createdBy: 'Joe Karow', + /** Optional: Longer description for the job */ + description: undefined, +} +/** + * Job export - this variable MUST be UNIQUE + */ +export const job20240424_update_crowdin_ids = { + title: `[${jobDef.jobId}] ${jobDef.title}`, + task: async (ctx, task) => { + const { createLogger, downloadFromDatastore, generateId, formatMessage, jobPostRunner, prisma } = ctx + /** Create logging instance */ + createLogger(task, jobDef.jobId) + const log = (...args: Parameters) => (task.output = formatMessage(...args)) + /** + * Start defining your data migration from here. + * + * To log output, use `task.output = 'Message to log'` + * + * This will be written to `stdout` and to a log file in `/prisma/migration-logs/` + */ + + // Do stuff + + const crowdinIds = DataSchema.parse( + await downloadFromDatastore('migrations/2024-04-24_update-crowdin-ids/data.json', log) + ) + const dbArgs = crowdinIds.map(({ crowdinId, key, ns }) => ({ + where: { ns_key: { ns, key } }, + data: { crowdinId }, + select: { key: true, crowdinId: true }, + })) + const totalCount = dbArgs.length + let i = 1 + + const updates = await prisma.$transaction( + async (tx) => { + const results: { key: string; crowdinId: number | null }[] = [] + + while (dbArgs.length) { + const batch = dbArgs.splice(0, 100) + log(`Processing records ${i} - ${i + batch.length - 1} of ${totalCount}`) + for (const args of batch) { + const update = await tx.translationKey.update(args) + results.push(update) + } + i += batch.length + } + + return results + }, + { timeout: ms('15m') } + ) + + log(`Updated ${updates.length} translation keys`) + /** + * DO NOT REMOVE BELOW + * + * This writes a record to the DB to register that this migration has run successfully. + */ + await jobPostRunner(jobDef) + }, + def: jobDef, +} satisfies MigrationJob diff --git a/packages/db/prisma/data-migrations/2024-04-25_translation-activation-flag.ts b/packages/db/prisma/data-migrations/2024-04-25_translation-activation-flag.ts new file mode 100644 index 00000000000..4f952af503e --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-04-25_translation-activation-flag.ts @@ -0,0 +1,79 @@ +import { type MigrationJob } from '~db/prisma/dataMigrationRunner' +import { type JobDef } from '~db/prisma/jobPreRun' + +/** Define the job metadata here. */ +const jobDef: JobDef = { + jobId: '2024-04-25_translation-activation-flag', + title: 'translation activation flag', + createdBy: 'JoeKarow', + /** Optional: Longer description for the job */ + description: undefined, +} +/** + * Job export - this variable MUST be UNIQUE + */ +export const job20240425_translation_activation_flag = { + title: `[${jobDef.jobId}] ${jobDef.title}`, + task: async (ctx, task) => { + const { createLogger, formatMessage, jobPostRunner, prisma } = ctx + /** Create logging instance */ + createLogger(task, jobDef.jobId) + const log = (...args: Parameters) => (task.output = formatMessage(...args)) + /** + * Start defining your data migration from here. + * + * To log output, use `task.output = 'Message to log'` + * + * This will be written to `stdout` and to a log file in `/prisma/migration-logs/` + */ + const unpublishedOrDeleted = { + OR: [{ published: false }, { deleted: true }] as [{ published: false }, { deleted: true }], + } + const notActive = { active: false } + const totalKeys = await prisma.translationKey.count() + // Do stuff + const update = await prisma.translationKey.updateMany({ + where: { + OR: [ + { attribute: notActive }, + { + freeText: { + OR: [ + { + AttributeSupplement: { + OR: [ + notActive, + { attribute: notActive }, + { location: unpublishedOrDeleted }, + { organization: unpublishedOrDeleted }, + { service: unpublishedOrDeleted }, + ], + }, + }, + { Organization: unpublishedOrDeleted }, + { OrgEmail: unpublishedOrDeleted }, + { OrgLocation: unpublishedOrDeleted }, + { OrgPhone: unpublishedOrDeleted }, + { OrgService: unpublishedOrDeleted }, + { OrgServiceName: unpublishedOrDeleted }, + { OrgWebsite: unpublishedOrDeleted }, + ], + }, + }, + ], + }, + data: { + active: false, + }, + }) + log(`Deactivated ${update.count} translation keys. (Total keys: ${totalKeys})`) + + /** + * DO NOT REMOVE BELOW + * + * This writes a record to the DB to register that this migration has run successfully. + */ + await jobPostRunner(jobDef) + }, + def: jobDef, +} satisfies MigrationJob diff --git a/packages/db/prisma/data-migrations/index.ts b/packages/db/prisma/data-migrations/index.ts index f49dd92b67a..a3e56c8c32f 100644 --- a/packages/db/prisma/data-migrations/index.ts +++ b/packages/db/prisma/data-migrations/index.ts @@ -13,4 +13,6 @@ export * from './2024-03-11_hide-locations' export * from './2024-03-15_update-dead-links/index' export * from './2024-03-21_attribute-supplement-schemas' export * from './2024-04-03_access-instruction-schemas' +export * from './2024-04-24_update-crowdin-ids' +export * from './2024-04-25_translation-activation-flag' // codegen:end diff --git a/packages/db/prisma/migrations/20240425151405_add_suggested_by/migration.sql b/packages/db/prisma/migrations/20240425151405_add_suggested_by/migration.sql new file mode 100644 index 00000000000..6c14840cd79 --- /dev/null +++ b/packages/db/prisma/migrations/20240425151405_add_suggested_by/migration.sql @@ -0,0 +1,57 @@ +/* + Warnings: + + - Made the column `organizationId` on table `Suggestion` required. This step will fail if there are existing NULL values in that column. + */ +-- DropForeignKey +ALTER TABLE "Suggestion" + DROP CONSTRAINT "Suggestion_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Suggestion" + ADD COLUMN "suggestedById" TEXT, + ALTER COLUMN "organizationId" SET NOT NULL; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "AttributeSupplement_active_attributeId_idx" ON + "AttributeSupplement"("active", "attributeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "OrgLocationService_active_serviceId_idx" ON + "OrgLocationService"("active", "serviceId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "OrgService_organizationId_published_deleted_idx" ON + "OrgService"("organizationId", "published" DESC, "deleted"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_organizationId_idx" ON + "ServiceArea"("active", "organizationId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_orgLocationId_idx" ON + "ServiceArea"("active", "orgLocationId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_orgServiceId_idx" ON + "ServiceArea"("active", "orgServiceId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceAreaCountry_active_serviceAreaId_idx" ON + "ServiceAreaCountry"("active", "serviceAreaId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceAreaDist_active_serviceAreaId_idx" ON + "ServiceAreaDist"("active", "serviceAreaId"); + +-- AddForeignKey +ALTER TABLE "Suggestion" + ADD CONSTRAINT "Suggestion_organizationId_fkey" FOREIGN KEY + ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT + ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Suggestion" + ADD CONSTRAINT "Suggestion_suggestedById_fkey" FOREIGN KEY + ("suggestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE + CASCADE; diff --git a/packages/db/prisma/migrations/20240425164515_translation_active_flag/migration.sql b/packages/db/prisma/migrations/20240425164515_translation_active_flag/migration.sql new file mode 100644 index 00000000000..fe9f4864fe0 --- /dev/null +++ b/packages/db/prisma/migrations/20240425164515_translation_active_flag/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "TranslationKey" + ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/packages/db/prisma/migrations/20240425164913_overwrite_file_on_export_flag/migration.sql b/packages/db/prisma/migrations/20240425164913_overwrite_file_on_export_flag/migration.sql new file mode 100644 index 00000000000..5432ffef78e --- /dev/null +++ b/packages/db/prisma/migrations/20240425164913_overwrite_file_on_export_flag/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "TranslationNamespace" + ADD COLUMN "overwriteFileOnExport" BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE + "TranslationNamespace" ns +SET + "overwriteFileOnExport" = TRUE +WHERE + ns.name != 'common' diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82229b3adab..c42584777f9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -151,6 +151,7 @@ model User { updatedAt DateTime @updatedAt //@@schema("user") + Suggestion Suggestion[] @@index([userTypeId]) } @@ -1228,9 +1229,11 @@ model Suggestion { id String @id @default(cuid()) data Json - organization Organization? @relation(fields: [organizationId], references: [id]) - organizationId String? + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String handled Boolean? + suggestedBy User? @relation(fields: [suggestedById], references: [id]) + suggestedById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1441,7 +1444,8 @@ model TranslationNamespace { attributeCategories AttributeCategory[] - exportFile Boolean @default(true) + exportFile Boolean @default(true) + overwriteFileOnExport Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1466,6 +1470,8 @@ model TranslationKey { crowdinId Int? + active Boolean @default(true) + // to manage plurals & ordinals interpolation InterpolationOptions? interpolationValues Json? diff --git a/packages/db/turbo/generators/templates/dataMigration.hbs b/packages/db/turbo/generators/templates/dataMigration.hbs index c681671e354..4396bfada7c 100644 --- a/packages/db/turbo/generators/templates/dataMigration.hbs +++ b/packages/db/turbo/generators/templates/dataMigration.hbs @@ -1,7 +1,5 @@ -import { prisma } from '~db/client' -import { formatMessage } from '~db/prisma/common' import { type MigrationJob } from '~db/prisma/dataMigrationRunner' -import { createLogger, type JobDef, jobPostRunner } from '~db/prisma/jobPreRun' +import { type JobDef } from '~db/prisma/jobPreRun' /** Define the job metadata here. */ const jobDef: JobDef = { diff --git a/packages/ui/components/data-portal/EmailTableDrawer.stories.tsx b/packages/ui/components/data-portal/EmailTableDrawer.stories.tsx deleted file mode 100644 index a606a0a5e4d..00000000000 --- a/packages/ui/components/data-portal/EmailTableDrawer.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { type Meta, type StoryObj } from '@storybook/react' - -import { Button } from '~ui/components/core/Button' -import { allFieldOptHandlers } from '~ui/mockData/fieldOpt' -import { location } from '~ui/mockData/location' -import { organization } from '~ui/mockData/organization' -import { orgEmail } from '~ui/mockData/orgEmail' -import { service } from '~ui/mockData/service' - -import { EmailTableDrawer } from './EmailTableDrawer' - -export default { - title: 'Data Portal/Drawers/Email Table', - component: EmailTableDrawer, - parameters: { - layout: 'fullscreen', - // layoutWrapper: 'centeredHalf', - rqDevtools: true, - nextjs: { - router: { - pathname: '/org/[slug]/edit', - asPath: '/org/mock-org-slug', - query: { - slug: 'mock-org-slug', - }, - }, - }, - msw: [ - orgEmail.get, - orgEmail.upsertMany, - organization.getIdFromSlug, - service.getNames, - location.getNames, - ...allFieldOptHandlers, - ], - }, - args: { - component: Button, - children: 'Open Drawer', - variant: 'inlineInvertedUtil1', - }, -} satisfies Meta - -type StoryDef = StoryObj - -export const Default = {} satisfies StoryDef diff --git a/packages/ui/components/data-portal/EmailTableDrawer.tsx b/packages/ui/components/data-portal/EmailTableDrawer.tsx deleted file mode 100644 index 41e24c21999..00000000000 --- a/packages/ui/components/data-portal/EmailTableDrawer.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import { - ActionIcon, - type ActionIconProps, - Anchor, - Box, - type ButtonProps, - Checkbox, - createPolymorphicComponent, - createStyles, - Drawer, - Group, - Modal, - Radio, - rem, - Select, - Stack, - Table, - TextInput, - type TextInputProps, - Tooltip, -} from '@mantine/core' -import { createFormContext, zodResolver } from '@mantine/form' -import { useDisclosure } from '@mantine/hooks' -import { - type CellContext, - type ColumnDef, - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable, -} from '@tanstack/react-table' -// import { useTranslation } from 'next-i18next' -import { forwardRef } from 'react' -import { z } from 'zod' - -import { transformNullString } from '@weareinreach/api/schemas/common' -import { Breadcrumb } from '~ui/components/core/Breadcrumb' -import { Button } from '~ui/components/core/Button' -import { useCustomVariant } from '~ui/hooks/useCustomVariant' -import { useOrgInfo } from '~ui/hooks/useOrgInfo' -import { Icon } from '~ui/icon' -import { trpc as api } from '~ui/lib/trpcClient' -import { PhoneEmailModal } from '~ui/modals/dataPortal/PhoneEmail' - -import { MultiSelectPopover } from './MultiSelectPopover' - -const [FormProvider, _useFormContext, useForm] = createFormContext<{ data: EmailTableColumns[] }>() - -const FormSchema = z.object({ - orgSlug: z.string().optional(), - data: z - .object({ - id: z.string().optional(), - email: z.string(), - firstName: z.string().nullable().transform(transformNullString), - lastName: z.string().nullable().transform(transformNullString), - title: z.string().nullable().transform(transformNullString), - description: z.string().optional(), - primary: z.boolean(), - published: z.boolean(), - deleted: z.boolean(), - locations: z.string().array(), - services: z.string().array(), - }) - .array(), -}) - -const useStyles = createStyles((theme) => ({ - addButton: { - display: 'flex', - flexWrap: 'nowrap', - padding: `${rem(12)} ${rem(8)}`, - gap: rem(8), - alignItems: 'center', - }, - deletedItem: { - textDecoration: 'line-through', - color: theme.other.colors.secondary.darkGray, - }, - unpublishedItem: { - color: theme.other.colors.secondary.darkGray, - }, - devtools: { - '& button': { backgroundColor: 'black !important' }, - }, -})) - -const conditionalStyles = ( - cellContext: CellContext, - classes: ReturnType['classes'] -) => { - const deleted = cellContext.row.getValue('deleted') - const published = cellContext.row.getValue('published') - return deleted ? classes.deletedItem : published ? undefined : classes.unpublishedItem -} - -interface DescriptionEditProps { - actionIconProps: ActionIconProps - textInputProps: TextInputProps -} -const DescriptionEdit = ({ actionIconProps, textInputProps }: DescriptionEditProps) => { - const [opened, handler] = useDisclosure(false) - return ( - <> - - - - - - - - - ) -} - -export const _EmailTableDrawer = forwardRef((props, ref) => { - const [opened, handler] = useDisclosure(false) - const form = useForm({ - initialValues: { data: [] }, - validate: zodResolver(FormSchema), - transformValues: FormSchema.parse, - }) - const { id: organizationId } = useOrgInfo() - const { classes } = useStyles() - // const { t } = useTranslation('phone-type') - // #region tRPC - const apiUtils = api.useUtils() - const variants = useCustomVariant() - const { data: _data } = api.orgEmail.get.useQuery( - { organizationId: organizationId ?? '' }, - { - enabled: Boolean(organizationId), - onSuccess: (data) => { - if (!form.values.data || form.values.data.length === 0) { - form.setValues({ - data: data.map(({ locations, organization, services, title, ...record }) => ({ - ...record, - locations: locations.map(({ id }) => id), - services: services.map(({ id }) => id), - title: title ?? 'NULL', - })), - }) - } - }, - } - ) - const { data: userTitles } = api.fieldOpt.userTitle.useQuery(undefined, { - enabled: Boolean(organizationId), - - // !fix when issue resolved. - select: (data) => [ - ...data.map(({ id, title }) => ({ value: id, label: title })), - { value: 'NULL', label: 'Custom...' }, - ], - refetchOnWindowFocus: false, - }) - const { data: orgServices } = api.service.getNames.useQuery( - { organizationId: organizationId ?? '' }, - { - enabled: Boolean(organizationId), - - // !fix when issue resolved. - select: (data) => data.map(({ id, defaultText }) => ({ value: id, label: defaultText })), - refetchOnWindowFocus: false, - } - ) - const { data: orgLocations } = api.location.getNames.useQuery( - { organizationId: organizationId ?? '' }, - { - enabled: Boolean(organizationId), - - // !fix when issue resolved. - select: (data) => data.map(({ id, name }) => ({ value: id, label: name ?? '' })), - refetchOnWindowFocus: false, - } - ) - const updateEmails = api.orgEmail.upsertMany.useMutation({ - onSuccess: () => apiUtils.orgEmail.get.invalidate({ organizationId }), - }) - - const handleUpdate = () => { - updateEmails.mutate({ orgId: organizationId ?? '', data: form.getTransformedValues().data }) - } - // #endregion - - // #region React Table Setup - const columnHelper = createColumnHelper() - const columns = [ - columnHelper.accessor('email', { - header: 'Email', - cell: (info) => ( - - form - .getInputProps(`data.${info.row.index}.email`, { withFocus: false }) - .onChange(e.target.value), - variant: variants.Input.small, - type: 'email', - }} - /> - ), - size: 200, - }), - columnHelper.accessor('firstName', { - header: 'First Name', - cell: (info) => { - return ( - - form - .getInputProps(`data.${info.row.index}.firstName`, { withFocus: false }) - .onChange(e.target.value), - variant: variants.Input.small, - }} - /> - ) - }, - }), - columnHelper.accessor('lastName', { - header: 'Last Name', - cell: (info) => { - return ( - - form - .getInputProps(`data.${info.row.index}.lastName`, { withFocus: false }) - .onChange(e.target.value), - variant: variants.Input.small, - }} - /> - ) - }, - }), - columnHelper.accessor('title', { - header: 'Title', - cell: (info) => { - return ( - - - - - - - ) - }, - size: 175, - }), - - columnHelper.accessor('primary', { - header: 'Primary', - cell: (info) => { - return ( - { - const newValues = form.values.data.map(({ primary, ...rest }, i) => - info.row.index === i ? { primary: true, ...rest } : { primary: false, ...rest } - ) - form.setValues({ data: newValues }) - }} - /> - ) - }, - size: 48, - }), - columnHelper.accessor('published', { - header: 'Published', - cell: (info) => ( - { - form.setFieldValue(`data.${info.row.index}.published`, e.target.checked) - }} - /> - ), - size: 48, - }), - columnHelper.accessor('services', { - header: 'Services', - cell: (info) => ( - - ), - }), - columnHelper.accessor('locations', { - header: 'Locations', - cell: (info) => ( - - ), - size: 150, - }), - columnHelper.accessor('deleted', { - header: 'Delete', - cell: (info) => { - const props = { - height: 24, - onClick: () => { - const currentVals = form.values.data[info.row.index] - if (!currentVals) throw new Error('Unable to get current values') - const { deleted, published, ...rest } = currentVals - const newVals = { - deleted: !info.getValue(), - published: info.getValue() ? published : false, - ...rest, - } - console.log(newVals) - form.setFieldValue(`data.${info.row.index}`, newVals) - }, - } - return info.getValue() ? ( - - ) : ( - - ) - }, - size: 48, - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] satisfies ColumnDef[] - const table = useReactTable({ - data: form.values.data, - columns, - getCoreRowModel: getCoreRowModel(), - }) - // #endregion - - console.log(form.values.data[0]) - console.log(form.getTransformedValues()) - - return ( - <> - - - - - - - - - - - Add new Phone Number - - - - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {flexRender(header.column.columnDef.header, header.getContext())} -
{flexRender(cell.column.columnDef.cell, cell.getContext())}
-
-
-
-
- - - - - - ) -}) -_PhoneTableDrawer.displayName = 'PhoneTableDrawer' - -export const PhoneTableDrawer = createPolymorphicComponent<'button', PhoneTableDrawerProps>(_PhoneTableDrawer) - -export interface PhoneTableDrawerProps extends ButtonProps { - x: string -} - -interface PhoneTableColumns { - id?: string - number: string - ext: string | null - country: { id: string; cca2: string } - phoneType?: string | null - description?: string - primary: boolean - published: boolean - deleted: boolean - locations: string[] - services: string[] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8dc2e3f6a6..29dddb0367e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,9 @@ importers: '@vercel/kv': specifier: 1.0.1 version: 1.0.1 + '@weareinreach/env': + specifier: workspace:* + version: link:../env '@weareinreach/util': specifier: workspace:* version: link:../util @@ -910,6 +913,9 @@ importers: pretty-bytes: specifier: 6.1.1 version: 6.1.1 + tiny-invariant: + specifier: 1.3.3 + version: 1.3.3 devDependencies: '@weareinreach/config': specifier: workspace:* @@ -974,6 +980,9 @@ importers: kysely: specifier: 0.27.3 version: 0.27.3 + ms: + specifier: 2.1.3 + version: 2.1.3 pg: specifier: 8.11.5 version: 8.11.5 @@ -1020,6 +1029,9 @@ importers: '@types/luxon': specifier: 3.4.2 version: 3.4.2 + '@types/ms': + specifier: 0.7.34 + version: 0.7.34 '@types/node': specifier: 20.12.7 version: 20.12.7 @@ -1047,6 +1059,9 @@ importers: eslint: specifier: 8.57.0 version: 8.57.0 + flat: + specifier: 6.0.1 + version: 6.0.1 google-auth-library: specifier: 9.9.0 version: 9.9.0(encoding@0.1.13)