db.ts 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184
  1. import { randomBytes, scryptSync } from 'node:crypto'
  2. import { Client } from 'pg'
  3. /**
  4. * A look into the database the suite seeded, for the few things a browser
  5. * cannot see. Mail is not one of them any more: what the app posts is read
  6. * back from the sink in `support/mail.ts`, which is what a person would see.
  7. * What is left is the secret behind a two-factor QR code.
  8. *
  9. * Plain pg rather than the app's Prisma client: the tests run in Playwright's
  10. * process, which has no adapter wired up, and one query does not need one.
  11. */
  12. async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
  13. const url = process.env.E2E_DATABASE_URL
  14. if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
  15. const db = new Client({ connectionString: url })
  16. await db.connect()
  17. try {
  18. return await fn(db)
  19. } finally {
  20. await db.end()
  21. }
  22. }
  23. /** The workshop the seeded owner belongs to. */
  24. export async function ownerOrganizationId(email = 'demo@torqvoice.com'): Promise<string> {
  25. return withDb(async (db) => {
  26. const result = await db.query<{ organizationId: string }>(
  27. `select m."organizationId"
  28. from organization_members m
  29. join users u on u.id = m."userId"
  30. where u.email = $1
  31. limit 1`,
  32. [email]
  33. )
  34. const id = result.rows[0]?.organizationId
  35. if (!id) throw new Error(`no organization for ${email}`)
  36. return id
  37. })
  38. }
  39. /** The workshop a signed-up account ended up owning, by the address it used. */
  40. export async function organizationIdFor(email: string): Promise<string> {
  41. return ownerOrganizationId(email)
  42. }
  43. /**
  44. * Everything a save from the invoice designer writes, as it stands now.
  45. *
  46. * The designer does not edit one record: it writes the workshop's live
  47. * layout, its palette and which design is in use, and that changes every
  48. * invoice printed afterwards — including the ones the pricing and parity
  49. * specs pin to the cent. Saving also graduates an organization from the
  50. * classic pre-designer sheet to the designer's, which is not something a
  51. * test may leave behind it. So the state is taken before and put back after.
  52. */
  53. export interface InvoiceDesignState {
  54. organizationId: string
  55. settings: { key: string; value: string }[]
  56. designIds: string[]
  57. }
  58. export async function invoiceDesignState(): Promise<InvoiceDesignState> {
  59. const organizationId = await ownerOrganizationId()
  60. return withDb(async (db) => {
  61. const settings = await db.query<{ key: string; value: string }>(
  62. `select key, value from app_settings where "organizationId" = $1 and key like 'invoice.%'`,
  63. [organizationId]
  64. )
  65. const designs = await db.query<{ id: string }>(
  66. `select id from document_designs where "organizationId" = $1`,
  67. [organizationId]
  68. )
  69. return {
  70. organizationId,
  71. settings: settings.rows,
  72. designIds: designs.rows.map((row) => row.id),
  73. }
  74. })
  75. }
  76. /** Puts the workshop back exactly as `invoiceDesignState` found it. */
  77. export async function restoreInvoiceDesignState(state: InvoiceDesignState): Promise<void> {
  78. const keys = state.settings.map((row) => row.key)
  79. await withDb(async (db) => {
  80. // Anything the designer added goes; anything it changed goes back.
  81. await db.query(
  82. `delete from app_settings
  83. where "organizationId" = $1 and key like 'invoice.%' and not (key = any($2::text[]))`,
  84. [state.organizationId, keys]
  85. )
  86. for (const row of state.settings) {
  87. await db.query(
  88. `update app_settings set value = $3 where "organizationId" = $1 and key = $2`,
  89. [state.organizationId, row.key, row.value]
  90. )
  91. }
  92. await db.query(
  93. `delete from document_designs
  94. where "organizationId" = $1 and not (id = any($2::text[]))`,
  95. [state.organizationId, state.designIds]
  96. )
  97. })
  98. }
  99. /**
  100. * Addresses of the seeded workshop's own records, for the tests that check a
  101. * different workshop cannot reach them. Read straight from the database
  102. * because the point is to ask for them as an outsider: going through the app
  103. * to find them first would need the very access under test.
  104. */
  105. export interface TenantFixtures {
  106. organizationId: string
  107. vehicleId: string
  108. serviceRecordId: string
  109. customerId: string
  110. quoteId: string
  111. /**
  112. * Words that belong to this workshop and nobody else. A cross-tenant page
  113. * can answer 200 and render an empty shell, which is a refusal too, so the
  114. * test asks whether any of these reached the screen rather than what the
  115. * status code was.
  116. */
  117. vehiclePlate: string
  118. customerName: string
  119. quoteNumber: string
  120. }
  121. /** A customer and a quote, each taken whole so its id and its words agree. */
  122. async function pairs(
  123. db: Client,
  124. organizationId: string
  125. ): Promise<Pick<TenantFixtures, 'customerId' | 'customerName' | 'quoteId' | 'quoteNumber'>> {
  126. const customer = await db.query<{ id: string; name: string }>(
  127. `select id, name from customers where "organizationId" = $1 order by "createdAt", id limit 1`,
  128. [organizationId]
  129. )
  130. if (!customer.rows[0]) throw new Error('the seeded workshop has no customer')
  131. const quote = await db.query<{ id: string; quoteNumber: string }>(
  132. `select id, "quoteNumber" from quotes
  133. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  134. order by "createdAt", id limit 1`,
  135. [organizationId]
  136. )
  137. if (!quote.rows[0]) throw new Error('the seeded workshop has no numbered quote')
  138. return {
  139. customerId: customer.rows[0].id,
  140. customerName: customer.rows[0].name,
  141. quoteId: quote.rows[0].id,
  142. quoteNumber: quote.rows[0].quoteNumber,
  143. }
  144. }
  145. export async function seededTenantFixtures(): Promise<TenantFixtures> {
  146. const organizationId = await ownerOrganizationId()
  147. return withDb(async (db) => {
  148. /**
  149. * A vehicle and one of its own jobs, from one row.
  150. *
  151. * Two queries answered this before, and on a database the suite had been
  152. * run against they happened to agree. On a fresh seed they did not, and
  153. * the job of one vehicle opened under the id of another draws a page with
  154. * nothing on it.
  155. *
  156. * The organisation comes off the vehicle: `service_records.organizationId`
  157. * is nullable and the seed leaves it null, scoping a job by the vehicle it
  158. * sits on.
  159. */
  160. const pair = await db.query<{
  161. vehicleId: string
  162. serviceRecordId: string
  163. licensePlate: string
  164. }>(
  165. `select v.id as "vehicleId", s.id as "serviceRecordId", v."licensePlate"
  166. from service_records s
  167. join vehicles v on v.id = s."vehicleId"
  168. where coalesce(s."organizationId", v."organizationId") = $1
  169. and v."licensePlate" is not null and v."licensePlate" <> ''
  170. order by s."createdAt"
  171. limit 1`,
  172. [organizationId]
  173. )
  174. const job = pair.rows[0]
  175. if (!job) throw new Error('the seeded workshop has no work order on a plated vehicle')
  176. return {
  177. organizationId,
  178. vehicleId: job.vehicleId,
  179. serviceRecordId: job.serviceRecordId,
  180. vehiclePlate: job.licensePlate,
  181. // Id and words from one row each, for the same reason the vehicle and
  182. // its job come from one row: `limit 1` without an order is not a
  183. // promise, and two queries for "a customer" can answer with two
  184. // different customers. That way round the id opens one record and the
  185. // name that is searched for on it belongs to another.
  186. ...(await pairs(db, organizationId)),
  187. }
  188. })
  189. }
  190. /**
  191. * Any work order belonging to a given workshop, for the tests that point one
  192. * workshop's credential at another's records. A workshop that has just been
  193. * opened has a few of its own from onboarding, which is what makes a
  194. * freshly signed-up account a usable target.
  195. */
  196. export async function foreignServiceRecordId(organizationId: string): Promise<string> {
  197. return withDb(async (db) => {
  198. const result = await db.query<{ id: string }>(
  199. `select s.id
  200. from service_records s
  201. join vehicles v on v.id = s."vehicleId"
  202. where coalesce(s."organizationId", v."organizationId") = $1
  203. order by s."createdAt"
  204. limit 1`,
  205. [organizationId]
  206. )
  207. const id = result.rows[0]?.id
  208. if (!id) throw new Error(`no work order in organization ${organizationId}`)
  209. return id
  210. })
  211. }
  212. /** The stored (encrypted) TOTP secret of a user, or null when 2FA is not set up. */
  213. export async function storedTwoFactorSecret(email: string): Promise<string | null> {
  214. return withDb(async (db) => {
  215. const result = await db.query<{ secret: string }>(
  216. `select tf.secret from two_factor tf join users u on u.id = tf."userId" where u.email = $1`,
  217. [email]
  218. )
  219. return result.rows[0]?.secret ?? null
  220. })
  221. }
  222. export interface StockedPart {
  223. id: string
  224. name: string
  225. /** What the ledger says it has on hand right now. */
  226. quantity: number
  227. }
  228. /**
  229. * A seeded inventory part with enough on hand to be consumed by a job, and
  230. * whose name is distinctive enough to search for in the picker.
  231. *
  232. * The part is chosen rather than created, because what is under test is the
  233. * path a workshop actually walks: pick a stocked part, use it, and watch the
  234. * count fall.
  235. */
  236. export async function stockedPart(organizationId: string, atLeast = 10): Promise<StockedPart> {
  237. return withDb(async (db) => {
  238. const result = await db.query<StockedPart>(
  239. `select id, name, quantity
  240. from inventory_parts
  241. where "organizationId" = $1 and quantity >= $2
  242. order by quantity desc, name
  243. limit 1`,
  244. [organizationId, atLeast]
  245. )
  246. const part = result.rows[0]
  247. if (!part) throw new Error(`no inventory part with ${atLeast} or more on hand`)
  248. return { ...part, quantity: Number(part.quantity) }
  249. })
  250. }
  251. /** What one inventory part has on hand. */
  252. export async function partQuantity(partId: string): Promise<number> {
  253. return withDb(async (db) => {
  254. const result = await db.query<{ quantity: number }>(
  255. `select quantity from inventory_parts where id = $1`,
  256. [partId]
  257. )
  258. if (!result.rows[0]) throw new Error(`no inventory part ${partId}`)
  259. return Number(result.rows[0].quantity)
  260. })
  261. }
  262. /** Set a part's quantity outright, to put the seed back as it was found. */
  263. export async function setPartQuantity(partId: string, quantity: number): Promise<void> {
  264. await withDb((db) =>
  265. db.query(`update inventory_parts set quantity = $2 where id = $1`, [partId, quantity])
  266. )
  267. }
  268. export interface StockMovement {
  269. delta: number
  270. quantityAfter: number
  271. reason: string
  272. serviceRecordId: string | null
  273. }
  274. /**
  275. * The ledger for one part, oldest first. Every movement is a row: the count on
  276. * the part is only ever the running total of these, which is why a spec that
  277. * checks stock checks both.
  278. */
  279. export async function stockMovements(
  280. partId: string,
  281. serviceRecordId?: string
  282. ): Promise<StockMovement[]> {
  283. return withDb(async (db) => {
  284. const result = await db.query<StockMovement>(
  285. `select delta, "quantityAfter", reason, "serviceRecordId"
  286. from stock_movements
  287. where "inventoryPartId" = $1
  288. and ($2::text is null or "serviceRecordId" = $2)
  289. order by "createdAt", id`,
  290. [partId, serviceRecordId ?? null]
  291. )
  292. return result.rows.map((row) => ({
  293. ...row,
  294. delta: Number(row.delta),
  295. quantityAfter: Number(row.quantityAfter),
  296. }))
  297. })
  298. }
  299. /** When a reminder is due, as the instant that was stored for it. */
  300. export async function reminderDueDate(title: string): Promise<Date> {
  301. return withDb(async (db) => {
  302. const result = await db.query<{ dueDate: Date }>(
  303. `select "dueDate" from reminders where title = $1 order by "createdAt" desc limit 1`,
  304. [title]
  305. )
  306. const due = result.rows[0]?.dueDate
  307. if (!due) throw new Error(`no reminder titled "${title}" with a due date`)
  308. return new Date(due)
  309. })
  310. }
  311. /** Removes the reminders a spec made, whatever state the page was left in. */
  312. export async function deleteRemindersTitled(title: string): Promise<void> {
  313. await withDb((db) => db.query(`delete from reminders where title = $1`, [title]))
  314. }
  315. /**
  316. * The newest file on a work order, as the app stored its address.
  317. *
  318. * A spec that needs a file belonging to one workshop uploads one and reads it
  319. * back here. Looking for a seeded one instead only worked on a database the
  320. * attachment spec had already run against.
  321. */
  322. export async function latestAttachmentUrl(serviceRecordId: string): Promise<string> {
  323. return withDb(async (db) => {
  324. const result = await db.query<{ fileUrl: string }>(
  325. `select "fileUrl" from service_attachments
  326. where "serviceRecordId" = $1 and "fileUrl" like '/api/protected/files/%'
  327. order by "createdAt" desc
  328. limit 1`,
  329. [serviceRecordId]
  330. )
  331. const url = result.rows[0]?.fileUrl
  332. if (!url) throw new Error(`no stored file on work order ${serviceRecordId}`)
  333. return url
  334. })
  335. }
  336. /**
  337. * Backdates a job's scheduled start to an hour ago. A new work order is
  338. * booked into the shop's next free slot, often tomorrow, and the financial
  339. * reports run up to the present moment, so a job made by a spec is not in
  340. * this year's tax report until it is moved into the past.
  341. */
  342. export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
  343. await withDb((db) =>
  344. db.query(
  345. `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
  346. [serviceRecordId]
  347. )
  348. )
  349. }
  350. /**
  351. * Email templates a spec made, gone again, and every kind back on its
  352. * built-in preset.
  353. *
  354. * The gallery's own delete is what a workshop uses and one test walks it, but
  355. * a file that fails halfway must not leave the workshop sending mail designed
  356. * by a test: the pointer is an `email.template.<kind>` setting, and a
  357. * template row it names is what the resolver prefers over the preset.
  358. */
  359. export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
  360. await withDb(async (db) => {
  361. await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
  362. await db.query(
  363. `delete from app_settings
  364. where key like 'email.template.%'
  365. and value not in (select 'design:' || id from email_templates)`
  366. )
  367. })
  368. }
  369. /** The names of the templates saved for one kind of mail. */
  370. export async function emailTemplateNames(kind: string): Promise<string[]> {
  371. return withDb(async (db) => {
  372. const result = await db.query<{ name: string }>(
  373. `select name from email_templates where kind = $1 order by "createdAt"`,
  374. [kind]
  375. )
  376. return result.rows.map((row) => row.name)
  377. })
  378. }
  379. /**
  380. * Whose car it is, and where to write to them.
  381. *
  382. * The customer of the vehicle a spec is working on, not the first customer in
  383. * the workshop: a message sent from a job goes to the owner of that car, so a
  384. * spec waiting on another customer's mailbox waits forever.
  385. */
  386. export async function customerOfVehicle(
  387. vehicleId: string
  388. ): Promise<{ name: string; email: string }> {
  389. return withDb(async (db) => {
  390. const result = await db.query<{ name: string; email: string }>(
  391. `select c.name, c.email
  392. from vehicles v
  393. join customers c on c.id = v."customerId"
  394. where v.id = $1`,
  395. [vehicleId]
  396. )
  397. const customer = result.rows[0]
  398. if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
  399. return customer
  400. })
  401. }
  402. /** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
  403. export async function connectionStatus(connectorId: string): Promise<string | null> {
  404. const organizationId = await ownerOrganizationId()
  405. return withDb(async (db) => {
  406. const result = await db.query<{ status: string }>(
  407. `select status from integration_connections
  408. where "organizationId" = $1 and "connectorId" = $2`,
  409. [organizationId, connectorId]
  410. )
  411. return result.rows[0]?.status ?? null
  412. })
  413. }
  414. /**
  415. * Every connection a spec made to a vendor, gone. The payment specs connect
  416. * Stripe and PayPal to the seeded workshop, and a connection left behind puts
  417. * pay buttons on every invoice the rest of the suite shares.
  418. */
  419. export async function forgetConnections(connectorIds: string[]): Promise<void> {
  420. const organizationId = await ownerOrganizationId()
  421. await withDb((db) =>
  422. db.query(
  423. `delete from integration_connections
  424. where "organizationId" = $1 and "connectorId" = any($2::text[])`,
  425. [organizationId, connectorIds]
  426. )
  427. )
  428. }
  429. export interface RecordedPayment {
  430. amount: number
  431. provider: string | null
  432. method: string
  433. externalId: string | null
  434. }
  435. /** The money recorded against one work order, oldest first. */
  436. export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
  437. return withDb(async (db) => {
  438. const result = await db.query<RecordedPayment>(
  439. `select amount, provider, method, "externalId" from payments
  440. where "serviceRecordId" = $1
  441. order by "createdAt", id`,
  442. [serviceRecordId]
  443. )
  444. return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
  445. })
  446. }
  447. /**
  448. * Writes a vendor payment row straight into the table, bypassing the app.
  449. *
  450. * For the one question only the database can answer: whether it refuses a
  451. * second row for a payment it already holds. Returns the Postgres error code
  452. * when the insert is refused, or null when it went in.
  453. */
  454. export async function insertVendorPaymentRow(row: {
  455. serviceRecordId: string
  456. provider: string
  457. externalId: string
  458. amount: number
  459. }): Promise<string | null> {
  460. return withDb(async (db) => {
  461. try {
  462. await db.query(
  463. `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
  464. values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
  465. [row.amount, row.provider, row.externalId, row.serviceRecordId]
  466. )
  467. return null
  468. } catch (error) {
  469. return (error as { code?: string }).code ?? 'unknown'
  470. }
  471. })
  472. }
  473. /**
  474. * How many rows one invoice holds for one vendor payment.
  475. *
  476. * Counted against the invoice as well as the id: a vendor's id means one
  477. * payment on one invoice, and a count across the whole table also finds any
  478. * other invoice that happens to carry the same id, which is not a duplicate.
  479. */
  480. export async function vendorPaymentRows(
  481. serviceRecordId: string,
  482. externalId: string
  483. ): Promise<number> {
  484. return withDb(async (db) => {
  485. const result = await db.query<{ n: number }>(
  486. `select count(*)::int as n from payments
  487. where "serviceRecordId" = $1 and "externalId" = $2`,
  488. [serviceRecordId, externalId]
  489. )
  490. return result.rows[0]?.n ?? 0
  491. })
  492. }
  493. /** Removes the rows a spec wrote for one vendor payment. */
  494. export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
  495. await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
  496. }
  497. /** The id of the user signed up with an address. */
  498. export async function userIdFor(email: string): Promise<string> {
  499. return withDb(async (db) => {
  500. const result = await db.query<{ id: string }>(
  501. `select id from users where lower(email) = lower($1)`,
  502. [email]
  503. )
  504. const id = result.rows[0]?.id
  505. if (!id) throw new Error(`no user with ${email}`)
  506. return id
  507. })
  508. }
  509. /**
  510. * Writes customers straight into a workshop, as if it had typed them in.
  511. *
  512. * For reaching a plan limit without twenty trips through a form: what is
  513. * under test is the one customer past the limit, and that one goes through
  514. * the app. These are real customers, not sample ones, so they count.
  515. */
  516. export async function insertCustomers(
  517. organizationId: string,
  518. userId: string,
  519. count: number,
  520. prefix: string
  521. ): Promise<void> {
  522. await withDb((db) =>
  523. db.query(
  524. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  525. select md5(random()::text || clock_timestamp()::text || n), $3 || ' ' || n, $2, $1, now()
  526. from generate_series(1, $4::int) as n`,
  527. [organizationId, userId, prefix, count]
  528. )
  529. )
  530. }
  531. /** Every customer row a workshop holds, sample ones included. */
  532. export async function customerRows(organizationId: string): Promise<number> {
  533. return withDb(async (db) => {
  534. const result = await db.query<{ n: number }>(
  535. `select count(*)::int as n from customers where "organizationId" = $1`,
  536. [organizationId]
  537. )
  538. return result.rows[0]?.n ?? 0
  539. })
  540. }
  541. /** Team invitations a workshop has sent. */
  542. export async function teamInvitations(organizationId: string): Promise<number> {
  543. return withDb(async (db) => {
  544. const result = await db.query<{ n: number }>(
  545. `select count(*)::int as n from team_invitations where "organizationId" = $1`,
  546. [organizationId]
  547. )
  548. return result.rows[0]?.n ?? 0
  549. })
  550. }
  551. /**
  552. * Puts a workshop on an active Pro subscription, as a paid checkout would.
  553. * Returns the plan's id so the spec can take it away again.
  554. */
  555. export async function giveProPlan(
  556. organizationId: string,
  557. stripe?: { subscriptionId: string; customerId: string }
  558. ): Promise<string> {
  559. return withDb(async (db) => {
  560. const plan = await db.query<{ id: string }>(
  561. `insert into subscription_plans (id, name, price, "updatedAt")
  562. values (md5(random()::text || clock_timestamp()::text), 'E2E Pro', 0, now())
  563. returning id`
  564. )
  565. const planId = plan.rows[0].id
  566. // With Stripe ids the row looks like a real purchase, which is what the
  567. // manage-subscription card and its buttons are shown for.
  568. await db.query(
  569. `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt",
  570. "stripeSubscriptionId", "stripeCustomerId")
  571. values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now(), $3, $4)`,
  572. [organizationId, planId, stripe?.subscriptionId ?? null, stripe?.customerId ?? null]
  573. )
  574. return planId
  575. })
  576. }
  577. /** Flags a subscription as ending at the period end, as a cancel through torqvoice.com would. */
  578. export async function setCancelAtPeriodEnd(organizationId: string, value: boolean): Promise<void> {
  579. await withDb((db) =>
  580. db.query(`update subscriptions set "cancelAtPeriodEnd" = $2 where "organizationId" = $1`, [
  581. organizationId,
  582. value,
  583. ])
  584. )
  585. }
  586. /** Takes a subscription and its plan away again. */
  587. export async function removePlan(organizationId: string, planId: string): Promise<void> {
  588. await withDb(async (db) => {
  589. await db.query(`delete from subscriptions where "organizationId" = $1`, [organizationId])
  590. await db.query(`delete from subscription_plans where id = $1`, [planId])
  591. })
  592. }
  593. export interface PersonRecord {
  594. /** How many users hold the address: more than one is two people where there should be one. */
  595. users: number
  596. /** How each of them can sign in: `credential` for a password, `google`. */
  597. providers: string[]
  598. emailVerified: boolean
  599. }
  600. /** Who holds an address, and how they can sign in. */
  601. export async function personWithEmail(email: string): Promise<PersonRecord> {
  602. return withDb(async (db) => {
  603. const users = await db.query<{ id: string; emailVerified: boolean }>(
  604. `select id, "emailVerified" from users where lower(email) = lower($1)`,
  605. [email]
  606. )
  607. const providers = await db.query<{ providerId: string }>(
  608. `select a."providerId" from accounts a join users u on u.id = a."userId"
  609. where lower(u.email) = lower($1) order by a."providerId"`,
  610. [email]
  611. )
  612. return {
  613. users: users.rows.length,
  614. providers: providers.rows.map((row) => row.providerId),
  615. emailVerified: users.rows.some((row) => row.emailVerified),
  616. }
  617. })
  618. }
  619. /** Every vehicle row a workshop holds. */
  620. export async function vehicleRows(organizationId: string): Promise<number> {
  621. return withDb(async (db) => {
  622. const result = await db.query<{ n: number }>(
  623. `select count(*)::int as n from vehicles where "organizationId" = $1`,
  624. [organizationId]
  625. )
  626. return result.rows[0]?.n ?? 0
  627. })
  628. }
  629. /** One of a workshop's settings as stored, or null when it was never saved. */
  630. export async function workshopSetting(organizationId: string, key: string): Promise<string | null> {
  631. return withDb(async (db) => {
  632. const result = await db.query<{ value: string }>(
  633. `select value from app_settings where "organizationId" = $1 and key = $2`,
  634. [organizationId, key]
  635. )
  636. return result.rows[0]?.value ?? null
  637. })
  638. }
  639. /**
  640. * A vehicle registry connected to a workshop, active, the way the header's
  641. * plate lookup looks for one. No keys: nothing is looked up, only offered.
  642. */
  643. export async function connectRegistry(
  644. organizationId: string,
  645. userId: string,
  646. connectorId: string
  647. ): Promise<void> {
  648. await withDb((db) =>
  649. db.query(
  650. `insert into integration_connections
  651. (id, "organizationId", "connectorId", status, "createdById", "updatedAt")
  652. values ($1, $2, $3, 'active', $4, now())`,
  653. [`e2e-${connectorId}-${Date.now()}`, organizationId, connectorId, userId]
  654. )
  655. )
  656. }
  657. export async function disconnectRegistry(
  658. organizationId: string,
  659. connectorId: string
  660. ): Promise<void> {
  661. await withDb((db) =>
  662. db.query(
  663. `delete from integration_connections where "organizationId" = $1 and "connectorId" = $2`,
  664. [organizationId, connectorId]
  665. )
  666. )
  667. }
  668. /** The id of a workshop's customer with exactly this name. */
  669. export async function customerIdNamed(organizationId: string, name: string): Promise<string> {
  670. return withDb(async (db) => {
  671. const result = await db.query<{ id: string }>(
  672. `select id from customers where "organizationId" = $1 and name = $2`,
  673. [organizationId, name]
  674. )
  675. const id = result.rows[0]?.id
  676. if (!id) throw new Error(`no customer named ${name}`)
  677. return id
  678. })
  679. }
  680. // ─── The security specs ──────────────────────────────────────────────────────
  681. /**
  682. * A custom role carrying every action on every subject the app knows, and no
  683. * admin standing. It is the sharpest test of "logged in is not allowed": a
  684. * member with this role passes every `requiredPermissions` check there is,
  685. * and the owner-only and admin-only actions have to refuse them anyway.
  686. */
  687. export async function createRoleWithEveryPermission(
  688. organizationId: string,
  689. name: string
  690. ): Promise<string> {
  691. const subjects = [
  692. 'dashboard',
  693. 'vehicles',
  694. 'customers',
  695. 'work_orders',
  696. 'quotes',
  697. 'services',
  698. 'billing',
  699. 'inventory',
  700. 'labor_presets',
  701. 'inspections',
  702. 'tire_hotel',
  703. 'reports',
  704. 'settings',
  705. 'work_board',
  706. 'ai_assistant',
  707. 'time_tracking',
  708. ]
  709. const actions = ['create', 'read', 'update', 'delete', 'manage']
  710. return withDb(async (db) => {
  711. const role = await db.query<{ id: string }>(
  712. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  713. values (gen_random_uuid()::text, $1, false, $2, now(), now())
  714. returning id`,
  715. [name, organizationId]
  716. )
  717. const roleId = role.rows[0].id
  718. for (const subject of subjects) {
  719. for (const action of actions) {
  720. await db.query(
  721. `insert into permissions (id, action, subject, "roleId")
  722. values (gen_random_uuid()::text, $1, $2, $3)`,
  723. [action, subject, roleId]
  724. )
  725. }
  726. }
  727. return roleId
  728. })
  729. }
  730. /** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
  731. export async function setMembership(
  732. email: string,
  733. organizationId: string,
  734. membership: { roleId: string | null; role: 'member' | 'admin' }
  735. ): Promise<void> {
  736. await withDb((db) =>
  737. db.query(
  738. `update organization_members m
  739. set "roleId" = $3, role = $4
  740. from users u
  741. where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
  742. [email, organizationId, membership.roleId, membership.role]
  743. )
  744. )
  745. }
  746. /** The credential in a pending invitation, or null when there is none for the address. */
  747. export async function invitationTokenFor(
  748. email: string,
  749. organizationId: string
  750. ): Promise<string | null> {
  751. return withDb(async (db) => {
  752. const result = await db.query<{ token: string }>(
  753. `select token from team_invitations
  754. where email = $1 and "organizationId" = $2 and status = 'pending'`,
  755. [email, organizationId]
  756. )
  757. return result.rows[0]?.token ?? null
  758. })
  759. }
  760. /** How much of the workshop there is, for a test that must find it all still there. */
  761. export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
  762. return withDb(async (db) => {
  763. const counts: Record<string, number> = {}
  764. for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
  765. const result = await db.query<{ n: string }>(
  766. `select count(*)::text as n from ${table} where "organizationId" = $1`,
  767. [organizationId]
  768. )
  769. counts[table] = Number(result.rows[0].n)
  770. }
  771. return counts
  772. })
  773. }
  774. /**
  775. * A file row written straight to the job, bypassing the schema that guards
  776. * the action: what a record carried before the guard existed, or what a
  777. * restore could bring in. The path resolver is the last line for these.
  778. */
  779. export async function insertServiceAttachment(row: {
  780. serviceRecordId: string
  781. fileName: string
  782. fileUrl: string
  783. fileType: string
  784. }): Promise<string> {
  785. return withDb(async (db) => {
  786. const result = await db.query<{ id: string }>(
  787. `insert into service_attachments
  788. (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
  789. values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
  790. returning id`,
  791. [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
  792. )
  793. return result.rows[0].id
  794. })
  795. }
  796. export async function deleteServiceAttachments(ids: string[]): Promise<void> {
  797. await withDb((db) =>
  798. db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
  799. )
  800. }
  801. /** How many file rows carry a name, on any job. */
  802. export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
  803. return withDb(async (db) => {
  804. const result = await db.query<{ n: string }>(
  805. `select count(*)::text as n from service_attachments where "fileName" = $1`,
  806. [fileName]
  807. )
  808. return Number(result.rows[0].n)
  809. })
  810. }
  811. /** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
  812. export async function insertConnection(row: {
  813. organizationId: string
  814. connectorId: string
  815. credentials: string
  816. settings: Record<string, unknown>
  817. createdById: string
  818. }): Promise<string> {
  819. return withDb(async (db) => {
  820. const result = await db.query<{ id: string }>(
  821. `insert into integration_connections
  822. (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
  823. values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
  824. returning id`,
  825. [
  826. row.organizationId,
  827. row.connectorId,
  828. row.credentials,
  829. JSON.stringify(row.settings),
  830. row.createdById,
  831. ]
  832. )
  833. return result.rows[0].id
  834. })
  835. }
  836. /** Inbound text messages with exactly this body, for a workshop. */
  837. export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
  838. return withDb(async (db) => {
  839. const result = await db.query<{ n: string }>(
  840. `select count(*)::text as n from sms_messages
  841. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  842. [organizationId, body]
  843. )
  844. return Number(result.rows[0].n)
  845. })
  846. }
  847. export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
  848. await withDb((db) =>
  849. db.query(
  850. `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  851. [organizationId, body]
  852. )
  853. )
  854. }
  855. // ─── Work order titles ───────────────────────────────────────────────────────
  856. /** What a job is called and numbered, straight from its row. */
  857. export async function serviceRecordNames(
  858. serviceRecordId: string
  859. ): Promise<{ title: string; invoiceNumber: string | null }> {
  860. return withDb(async (db) => {
  861. const result = await db.query<{ title: string; invoiceNumber: string | null }>(
  862. `select title, "invoiceNumber" from service_records where id = $1`,
  863. [serviceRecordId]
  864. )
  865. const row = result.rows[0]
  866. if (!row) throw new Error(`no work order ${serviceRecordId}`)
  867. return row
  868. })
  869. }
  870. /** The words a title template can print about one vehicle and its owner. */
  871. export async function vehicleFacts(vehicleId: string): Promise<{
  872. licensePlate: string | null
  873. make: string
  874. model: string
  875. year: number
  876. vin: string | null
  877. customerName: string | null
  878. }> {
  879. return withDb(async (db) => {
  880. const result = await db.query<{
  881. licensePlate: string | null
  882. make: string
  883. model: string
  884. year: number
  885. vin: string | null
  886. customerName: string | null
  887. }>(
  888. `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
  889. from vehicles v
  890. left join customers c on c.id = v."customerId"
  891. where v.id = $1`,
  892. [vehicleId]
  893. )
  894. const row = result.rows[0]
  895. if (!row) throw new Error(`no vehicle ${vehicleId}`)
  896. return row
  897. })
  898. }
  899. /** Removes a workshop setting so the app falls back to its default for it. */
  900. export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
  901. await withDb((db) =>
  902. db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
  903. organizationId,
  904. key,
  905. ])
  906. )
  907. }
  908. /** Marks the address verified, as clicking the mail's link would. */
  909. export async function markEmailVerified(email: string): Promise<void> {
  910. await withDb((db) =>
  911. db.query(`update users set "emailVerified" = true where lower(email) = lower($1)`, [email])
  912. )
  913. }
  914. export interface MembershipRecord {
  915. id: string
  916. role: string
  917. roleId: string | null
  918. }
  919. /** A person's membership of a workshop, as stored. */
  920. export async function membershipOf(
  921. email: string,
  922. organizationId: string
  923. ): Promise<MembershipRecord> {
  924. return withDb(async (db) => {
  925. const result = await db.query<MembershipRecord>(
  926. `select m.id, m.role, m."roleId" from organization_members m
  927. join users u on u.id = m."userId"
  928. where lower(u.email) = lower($1) and m."organizationId" = $2`,
  929. [email, organizationId]
  930. )
  931. if (!result.rows[0]) throw new Error(`${email} is not a member of ${organizationId}`)
  932. return result.rows[0]
  933. })
  934. }
  935. /** A role that carries the admin switch and nothing else. */
  936. export async function createAdminRole(organizationId: string, name: string): Promise<string> {
  937. return withDb(async (db) => {
  938. const result = await db.query<{ id: string }>(
  939. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  940. values (gen_random_uuid()::text, $1, true, $2, now(), now()) returning id`,
  941. [name, organizationId]
  942. )
  943. return result.rows[0].id
  944. })
  945. }
  946. export async function deleteRoles(ids: string[]): Promise<void> {
  947. if (ids.length === 0) return
  948. await withDb((db) => db.query(`delete from roles where id = any($1::text[])`, [ids]))
  949. }
  950. /** A technician on a workshop's board, made here so the spec owns it. */
  951. export async function insertTechnician(organizationId: string, name: string): Promise<string> {
  952. return withDb(async (db) => {
  953. const result = await db.query<{ id: string }>(
  954. `insert into technicians (id, name, "organizationId", "createdAt", "updatedAt")
  955. values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
  956. [name, organizationId]
  957. )
  958. return result.rows[0].id
  959. })
  960. }
  961. export async function insertWorkBay(organizationId: string, name: string): Promise<string> {
  962. return withDb(async (db) => {
  963. const result = await db.query<{ id: string }>(
  964. `insert into work_bays (id, name, "organizationId", "createdAt", "updatedAt")
  965. values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
  966. [name, organizationId]
  967. )
  968. return result.rows[0].id
  969. })
  970. }
  971. export async function deleteTechnicians(ids: string[]): Promise<void> {
  972. if (ids.length === 0) return
  973. await withDb((db) => db.query(`delete from technicians where id = any($1::text[])`, [ids]))
  974. }
  975. export async function deleteWorkBays(ids: string[]): Promise<void> {
  976. if (ids.length === 0) return
  977. await withDb((db) => db.query(`delete from work_bays where id = any($1::text[])`, [ids]))
  978. }
  979. export interface JobAssignment {
  980. id: string
  981. technicianId: string | null
  982. workBayId: string | null
  983. }
  984. /** A job's technician and bay as stored, by its id. */
  985. export async function jobAssignment(serviceRecordId: string): Promise<JobAssignment> {
  986. return withDb(async (db) => {
  987. const result = await db.query<JobAssignment>(
  988. `select id, "technicianId", "workBayId" from service_records where id = $1`,
  989. [serviceRecordId]
  990. )
  991. if (!result.rows[0]) throw new Error(`no job ${serviceRecordId}`)
  992. return result.rows[0]
  993. })
  994. }
  995. /** Everybody on a job beside its technician, as technician ids, sorted. */
  996. export async function jobHelperIds(serviceRecordId: string): Promise<string[]> {
  997. return withDb(async (db) => {
  998. const result = await db.query<{ technicianId: string }>(
  999. `select "technicianId" from service_record_helpers
  1000. where "serviceRecordId" = $1 order by "technicianId"`,
  1001. [serviceRecordId]
  1002. )
  1003. return result.rows.map((row) => row.technicianId)
  1004. })
  1005. }
  1006. /**
  1007. * When a job is booked to start. The column has no zone and holds UTC, which
  1008. * pg would read in the test process's zone, so it is read as UTC text.
  1009. */
  1010. export async function jobStart(serviceRecordId: string): Promise<Date | null> {
  1011. return withDb(async (db) => {
  1012. const result = await db.query<{ start: string | null }>(
  1013. `select to_char("startDateTime", 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as start
  1014. from service_records where id = $1`,
  1015. [serviceRecordId]
  1016. )
  1017. const start = result.rows[0]?.start
  1018. return start ? new Date(start) : null
  1019. })
  1020. }
  1021. /** A workshop's technician by name, for one the desk made through the page. */
  1022. export async function technicianIdNamed(organizationId: string, name: string): Promise<string> {
  1023. return withDb(async (db) => {
  1024. const result = await db.query<{ id: string }>(
  1025. `select id from technicians where "organizationId" = $1 and name = $2 limit 1`,
  1026. [organizationId, name]
  1027. )
  1028. const id = result.rows[0]?.id
  1029. if (!id) throw new Error(`no technician named ${name}`)
  1030. return id
  1031. })
  1032. }
  1033. /** How many jobs a vehicle has, before and after an attempt to add one. */
  1034. export async function jobCount(vehicleId: string): Promise<number> {
  1035. return withDb(async (db) => {
  1036. const result = await db.query<{ n: string }>(
  1037. `select count(*)::text as n from service_records where "vehicleId" = $1`,
  1038. [vehicleId]
  1039. )
  1040. return Number(result.rows[0].n)
  1041. })
  1042. }
  1043. /** Inbound WhatsApp messages with exactly this body, for a workshop. */
  1044. export async function inboundWhatsappCount(organizationId: string, body: string): Promise<number> {
  1045. return withDb(async (db) => {
  1046. const result = await db.query<{ n: string }>(
  1047. `select count(*)::text as n from whatsapp_messages
  1048. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  1049. [organizationId, body]
  1050. )
  1051. return Number(result.rows[0].n)
  1052. })
  1053. }
  1054. export async function deleteInboundWhatsapp(organizationId: string, body: string): Promise<void> {
  1055. await withDb((db) =>
  1056. db.query(
  1057. `delete from whatsapp_messages where "organizationId" = $1 and direction = 'inbound' and body like $2`,
  1058. [organizationId, `${body}%`]
  1059. )
  1060. )
  1061. }
  1062. /** Open sessions a person has, however many browsers and phones that is. */
  1063. export async function sessionCountFor(email: string): Promise<number> {
  1064. return withDb(async (db) => {
  1065. const result = await db.query<{ n: string }>(
  1066. `select count(*)::text as n from sessions s join users u on u.id = s."userId"
  1067. where lower(u.email) = lower($1) and s."expiresAt" > now()`,
  1068. [email]
  1069. )
  1070. return Number(result.rows[0].n)
  1071. })
  1072. }
  1073. /**
  1074. * Makes every open sign-in of a person look `minutes` old, the way a tab left
  1075. * open ages. What the app asks of a sign-in that is no longer recent cannot
  1076. * be waited for in a test.
  1077. */
  1078. export async function ageSessionsOf(email: string, minutes: number): Promise<void> {
  1079. await withDb(async (db) => {
  1080. await db.query(
  1081. `update sessions s set "createdAt" = now() - make_interval(mins => $2::int)
  1082. from users u
  1083. where u.id = s."userId" and lower(u.email) = lower($1)`,
  1084. [email, minutes]
  1085. )
  1086. })
  1087. }
  1088. /** Device rows a person has whose user agent mentions `needle`. */
  1089. export async function deviceCountFor(email: string, needle: string): Promise<number> {
  1090. return withDb(async (db) => {
  1091. const result = await db.query<{ n: string }>(
  1092. `select count(*)::text as n from user_devices d join users u on u.id = d."userId"
  1093. where lower(u.email) = lower($1) and d."userAgent" like $2`,
  1094. [email, `%${needle}%`]
  1095. )
  1096. return Number(result.rows[0].n)
  1097. })
  1098. }
  1099. /**
  1100. * Matches better-auth's scrypt parameters, the same way the seed does, so a
  1101. * password written here is accepted by the sign-in form.
  1102. */
  1103. function hashPassword(password: string): string {
  1104. const N = 16384
  1105. const r = 16
  1106. const p = 1
  1107. const salt = randomBytes(16).toString('hex')
  1108. const key = scryptSync(password.normalize('NFKC'), salt, 64, { N, r, p, maxmem: 128 * N * r * 2 })
  1109. return `${salt}:${key.toString('hex')}`
  1110. }
  1111. export interface PlantedWorkshop {
  1112. userId: string
  1113. organizationId: string
  1114. }
  1115. /**
  1116. * A second tenant, put straight into the database.
  1117. *
  1118. * A self-hosted install opens one workshop; every later sign-up is told to
  1119. * ask for an invitation. A spec that needs a second, separate workshop to
  1120. * prove isolation therefore cannot sign one up and has to plant it: a
  1121. * verified person with a password, and a workshop they own.
  1122. */
  1123. export async function plantWorkshop(input: {
  1124. name: string
  1125. email: string
  1126. password: string
  1127. workshopName: string
  1128. }): Promise<PlantedWorkshop> {
  1129. return withDb(async (db) => {
  1130. const user = await db.query<{ id: string }>(
  1131. `insert into users (id, name, email, "emailVerified", "termsAcceptedAt", "createdAt", "updatedAt")
  1132. values (md5(random()::text || clock_timestamp()::text), $1, $2, true, now(), now(), now())
  1133. returning id`,
  1134. [input.name, input.email.toLowerCase()]
  1135. )
  1136. const userId = user.rows[0].id
  1137. await db.query(
  1138. `insert into accounts (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt")
  1139. values (md5(random()::text || clock_timestamp()::text), $1, 'credential', $1, $2, now(), now())`,
  1140. [userId, hashPassword(input.password)]
  1141. )
  1142. const org = await db.query<{ id: string }>(
  1143. `insert into organizations (id, name, "createdAt", "updatedAt")
  1144. values (md5(random()::text || clock_timestamp()::text), $1, now(), now())
  1145. returning id`,
  1146. [input.workshopName]
  1147. )
  1148. const organizationId = org.rows[0].id
  1149. await db.query(
  1150. `insert into organization_members (id, role, "userId", "organizationId")
  1151. values (md5(random()::text || clock_timestamp()::text), 'owner', $1, $2)`,
  1152. [userId, organizationId]
  1153. )
  1154. return { userId, organizationId }
  1155. })
  1156. }
  1157. /** Removes a person and, through the cascade, their memberships and sessions. */
  1158. export async function deletePersonWithEmail(email: string): Promise<void> {
  1159. await withDb((db) => db.query(`delete from users where lower(email) = lower($1)`, [email]))
  1160. }
  1161. /** How many workshops the install has. */
  1162. export async function organizationCount(): Promise<number> {
  1163. return withDb(async (db) => {
  1164. const result = await db.query<{ count: string }>(
  1165. `select count(*)::text as count from organizations`
  1166. )
  1167. return Number(result.rows[0].count)
  1168. })
  1169. }
  1170. /**
  1171. * One customer, one vehicle and one work order in a workshop, for a spec
  1172. * that needs a job to point at. A planted workshop has none of the sample
  1173. * data onboarding would have given it.
  1174. */
  1175. export async function plantJob(
  1176. organizationId: string,
  1177. userId: string,
  1178. title: string
  1179. ): Promise<{ serviceRecordId: string; vehicleId: string }> {
  1180. return withDb(async (db) => {
  1181. const customer = await db.query<{ id: string }>(
  1182. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  1183. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1184. returning id`,
  1185. [`${title} customer`, userId, organizationId]
  1186. )
  1187. const vehicle = await db.query<{ id: string }>(
  1188. `insert into vehicles (id, make, model, year, "userId", "organizationId", "customerId", "updatedAt")
  1189. values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2020, $2, $3, $4, now())
  1190. returning id`,
  1191. [title, userId, organizationId, customer.rows[0].id]
  1192. )
  1193. const vehicleId = vehicle.rows[0].id
  1194. const job = await db.query<{ id: string }>(
  1195. `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
  1196. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1197. returning id`,
  1198. [title, vehicleId, organizationId]
  1199. )
  1200. return { serviceRecordId: job.rows[0].id, vehicleId }
  1201. })
  1202. }
  1203. // ─── Notifications ───────────────────────────────────────────────────────────
  1204. export interface PlantedNotification {
  1205. type: string
  1206. title: string
  1207. message: string
  1208. entityType: string
  1209. entityId: string
  1210. entityUrl: string
  1211. }
  1212. /**
  1213. * A notification written straight into the bell, with the address the code
  1214. * that raises it builds. Planting it rather than provoking it lets a spec
  1215. * follow links whose trigger needs a provider the harness cannot play (an
  1216. * inbound SMS, a Telegram webhook), and also links already stored in the old
  1217. * shape, which the pages still have to honour.
  1218. */
  1219. export async function plantNotification(
  1220. organizationId: string,
  1221. n: PlantedNotification
  1222. ): Promise<string> {
  1223. return withDb(async (db) => {
  1224. const result = await db.query<{ id: string }>(
  1225. `insert into notifications (id, type, title, message, "entityType", "entityId", "entityUrl", read, "organizationId", "createdAt")
  1226. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, false, $7, now())
  1227. returning id`,
  1228. [n.type, n.title, n.message, n.entityType, n.entityId, n.entityUrl, organizationId]
  1229. )
  1230. return result.rows[0].id
  1231. })
  1232. }
  1233. export async function deleteNotifications(ids: string[]): Promise<void> {
  1234. if (ids.length === 0) return
  1235. await withDb((db) => db.query('delete from notifications where id = any($1)', [ids]))
  1236. }
  1237. /** An inbound message on a customer's thread, as the webhook would have stored it. */
  1238. export async function plantInboundMessage(
  1239. channel: 'sms' | 'telegram',
  1240. organizationId: string,
  1241. customerId: string,
  1242. body: string
  1243. ): Promise<void> {
  1244. await withDb((db) =>
  1245. channel === 'sms'
  1246. ? db.query(
  1247. `insert into sms_messages (id, direction, "fromNumber", "toNumber", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1248. values (md5(random()::text || clock_timestamp()::text), 'inbound', '+4790000000', '+4790000001', $1, 'received', $2, $3, now(), now())`,
  1249. [body, organizationId, customerId]
  1250. )
  1251. : db.query(
  1252. `insert into telegram_messages (id, direction, "chatId", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1253. values (md5(random()::text || clock_timestamp()::text), 'inbound', '777000', $1, 'received', $2, $3, now(), now())`,
  1254. [body, organizationId, customerId]
  1255. )
  1256. )
  1257. }
  1258. /**
  1259. * Links a customer to a Telegram chat and hands back what was there before.
  1260. * A real inbound Telegram message only ever comes from a linked chat, and the
  1261. * conversation shows nothing but "not connected yet" without one.
  1262. */
  1263. export async function linkTelegramChat(
  1264. customerId: string,
  1265. chatId: string | null
  1266. ): Promise<string | null> {
  1267. return withDb(async (db) => {
  1268. const before = await db.query<{ telegramChatId: string | null }>(
  1269. 'select "telegramChatId" from customers where id = $1',
  1270. [customerId]
  1271. )
  1272. await db.query('update customers set "telegramChatId" = $1 where id = $2', [chatId, customerId])
  1273. return before.rows[0]?.telegramChatId ?? null
  1274. })
  1275. }
  1276. export async function deleteMessagesWithBody(body: string): Promise<void> {
  1277. await withDb(async (db) => {
  1278. await db.query('delete from sms_messages where body = $1', [body])
  1279. await db.query('delete from telegram_messages where body = $1', [body])
  1280. })
  1281. }
  1282. /** A vehicle job with the customer it belongs to, taken from one row so the ids agree. */
  1283. export async function jobWithCustomer(organizationId: string): Promise<{
  1284. vehicleId: string
  1285. serviceRecordId: string
  1286. customerId: string
  1287. customerName: string
  1288. }> {
  1289. return withDb(async (db) => {
  1290. const result = await db.query<{
  1291. vehicleId: string
  1292. serviceRecordId: string
  1293. customerId: string
  1294. customerName: string
  1295. }>(
  1296. `select v.id as "vehicleId", s.id as "serviceRecordId", c.id as "customerId", c.name as "customerName"
  1297. from service_records s
  1298. join vehicles v on v.id = s."vehicleId"
  1299. join customers c on c.id = v."customerId"
  1300. where s."organizationId" = $1
  1301. order by s."createdAt" asc
  1302. limit 1`,
  1303. [organizationId]
  1304. )
  1305. const row = result.rows[0]
  1306. if (!row) throw new Error('the seeded workshop has no vehicle job with a customer')
  1307. return row
  1308. })
  1309. }
  1310. export interface PlantedVehicleFiles {
  1311. vehicleImage: string
  1312. jobPhoto: string
  1313. /** A tire set's photo, also on the job as a tire hotel copy. */
  1314. tireSetPhoto: string
  1315. statusVideo: string
  1316. inspectionPhoto?: string
  1317. quoteDocument: string
  1318. /** A URL naming another workshop, as a row restored from its backup can. */
  1319. foreignPhoto: string
  1320. }
  1321. export interface PlantedVehicle {
  1322. vehicleId: string
  1323. serviceRecordId: string
  1324. tireSetId: string
  1325. quoteId: string
  1326. inspected: boolean
  1327. }
  1328. /**
  1329. * A vehicle with every kind of file that can go with it, written straight
  1330. * into the database so a spec knows exactly which rows point at which file:
  1331. * its image; a job with a photo, a status report video and the copy of a tire
  1332. * set's photo; an inspection with a photo on one item (when the workshop has
  1333. * a template to hang it on); and, pointing at the same vehicle but not
  1334. * deleted with it, a stored tire set and a quote with a document.
  1335. */
  1336. export async function plantVehicleWithFiles(
  1337. organizationId: string,
  1338. userId: string,
  1339. files: PlantedVehicleFiles,
  1340. label: string
  1341. ): Promise<PlantedVehicle> {
  1342. return withDb(async (db) => {
  1343. const id = () => randomBytes(12).toString('hex')
  1344. const vehicleId = id()
  1345. const serviceRecordId = id()
  1346. const tireSetId = id()
  1347. const quoteId = id()
  1348. await db.query(
  1349. `insert into vehicles (id, make, model, year, "userId", "organizationId", "imageUrl", "updatedAt")
  1350. values ($1, 'E2E', $2, 2020, $3, $4, $5, now())`,
  1351. [vehicleId, label, userId, organizationId, files.vehicleImage]
  1352. )
  1353. await db.query(
  1354. `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
  1355. values ($1, $2, $3, $4, now())`,
  1356. [serviceRecordId, `${label} job`, vehicleId, organizationId]
  1357. )
  1358. await db.query(
  1359. `insert into tire_sets (id, "organizationId", "userId", "vehicleId", "updatedAt")
  1360. values ($1, $2, $3, $4, now())`,
  1361. [tireSetId, organizationId, userId, vehicleId]
  1362. )
  1363. await db.query(
  1364. `insert into tire_set_attachments (id, "organizationId", "tireSetId", "fileName", "fileUrl", "fileType", "fileSize")
  1365. values ($1, $2, $3, 'rim.jpg', $4, 'image/jpeg', 10)`,
  1366. [id(), organizationId, tireSetId, files.tireSetPhoto]
  1367. )
  1368. for (const [fileUrl, category] of [
  1369. [files.jobPhoto, 'image'],
  1370. [files.tireSetPhoto, 'tire_hotel'],
  1371. [files.foreignPhoto, 'image'],
  1372. ]) {
  1373. await db.query(
  1374. `insert into service_attachments (id, "serviceRecordId", "fileName", "fileUrl", "fileType", "fileSize", category)
  1375. values ($1, $2, 'photo.jpg', $3, 'image/jpeg', 10, $4)`,
  1376. [id(), serviceRecordId, fileUrl, category]
  1377. )
  1378. }
  1379. await db.query(
  1380. `insert into status_reports (id, "publicToken", "organizationId", "serviceRecordId", "videoUrl", "updatedAt")
  1381. values ($1, $2, $3, $4, $5, now())`,
  1382. [id(), id(), organizationId, serviceRecordId, files.statusVideo]
  1383. )
  1384. await db.query(
  1385. `insert into quotes (id, title, "userId", "organizationId", "vehicleId", "updatedAt")
  1386. values ($1, $2, $3, $4, $5, now())`,
  1387. [quoteId, `${label} quote`, userId, organizationId, vehicleId]
  1388. )
  1389. await db.query(
  1390. `insert into quote_attachments (id, "quoteId", "fileName", "fileUrl", "fileType", "fileSize")
  1391. values ($1, $2, 'estimate.pdf', $3, 'application/pdf', 10)`,
  1392. [id(), quoteId, files.quoteDocument]
  1393. )
  1394. let inspected = false
  1395. const template = await db.query<{ id: string }>(
  1396. `select id from inspection_templates where "organizationId" = $1 limit 1`,
  1397. [organizationId]
  1398. )
  1399. if (files.inspectionPhoto && template.rows[0]) {
  1400. const inspectionId = id()
  1401. await db.query(
  1402. `insert into inspections (id, "vehicleId", "organizationId", "templateId", "updatedAt")
  1403. values ($1, $2, $3, $4, now())`,
  1404. [inspectionId, vehicleId, organizationId, template.rows[0].id]
  1405. )
  1406. await db.query(
  1407. `insert into inspection_items (id, "inspectionId", name, section, "imageUrls")
  1408. values ($1, $2, 'Brakes', 'Checks', $3)`,
  1409. [id(), inspectionId, [files.inspectionPhoto]]
  1410. )
  1411. inspected = true
  1412. }
  1413. return { vehicleId, serviceRecordId, tireSetId, quoteId, inspected }
  1414. })
  1415. }
  1416. /** Removes what `plantVehicleWithFiles` made that its spec did not delete. */
  1417. export async function removePlantedVehicle(planted: PlantedVehicle): Promise<void> {
  1418. await withDb(async (db) => {
  1419. await db.query('delete from quotes where id = $1', [planted.quoteId])
  1420. await db.query('delete from tire_sets where id = $1', [planted.tireSetId])
  1421. await db.query('delete from inspections where "vehicleId" = $1', [planted.vehicleId])
  1422. await db.query('delete from vehicles where id = $1', [planted.vehicleId])
  1423. })
  1424. }
  1425. /**
  1426. * Every permission refusal logged for one person since a moment in time.
  1427. *
  1428. * `withAuth` writes an `auth.permissionDenied` row whenever a role is short of
  1429. * what an action asked for, which makes the audit log the one place that says
  1430. * what a page quietly wanted and did not get. A refusal on a page the role is
  1431. * meant to reach is, by definition, a bug: the page renders anyway, falls back
  1432. * to a built-in default, and says nothing about it.
  1433. *
  1434. * The write is fire-and-forget, so give it a moment to land before counting.
  1435. */
  1436. export async function permissionDenialsFor(email: string, since: Date): Promise<string[]> {
  1437. return withDb(async (db) => {
  1438. const result = await db.query<{ message: string }>(
  1439. `select coalesce(a.message, a.action) as message
  1440. from audit_logs a
  1441. join users u on u.id = a."userId"
  1442. where lower(u.email) = lower($1)
  1443. and a.action = 'auth.permissionDenied'
  1444. and a.timestamp >= $2
  1445. order by a.timestamp`,
  1446. [email, since]
  1447. )
  1448. return result.rows.map((row) => row.message)
  1449. })
  1450. }
  1451. /**
  1452. * What the audit log says about one person's sign-in security since a moment:
  1453. * two-factor sign-in turned on or off, a backup code used, a passkey added or
  1454. * removed. Read as the English line the log shows.
  1455. *
  1456. * The writes are fire-and-forget, so poll this rather than read it once.
  1457. */
  1458. export async function accountSecurityEntries(email: string, since: Date): Promise<string[]> {
  1459. return withDb(async (db) => {
  1460. const result = await db.query<{ message: string }>(
  1461. `select coalesce(a.message, a.action) as message
  1462. from audit_logs a
  1463. join users u on u.id = a."userId"
  1464. where lower(u.email) = lower($1)
  1465. and a.action in ('auth.twoFactorEnabled', 'auth.twoFactorDisabled', 'auth.backupCodeUsed',
  1466. 'auth.passkeyAdded', 'auth.passkeyRemoved')
  1467. and a.timestamp >= $2
  1468. order by a.timestamp`,
  1469. [email, since]
  1470. )
  1471. return result.rows.map((row) => row.message)
  1472. })
  1473. }
  1474. /**
  1475. * Sets one of a workshop's settings directly, returning what was there before
  1476. * (null when the key had never been saved), so a spec can put it back.
  1477. *
  1478. * The row needs an owner: `app_settings.userId` is not nullable, so a key the
  1479. * workshop has never saved is attributed to whoever owns the workshop.
  1480. */
  1481. export async function setWorkshopSetting(
  1482. organizationId: string,
  1483. key: string,
  1484. value: string
  1485. ): Promise<string | null> {
  1486. return withDb(async (db) => {
  1487. const before = await db.query<{ value: string }>(
  1488. `select value from app_settings where "organizationId" = $1 and key = $2`,
  1489. [organizationId, key]
  1490. )
  1491. await db.query(
  1492. `insert into app_settings (id, key, value, "userId", "organizationId")
  1493. values (gen_random_uuid()::text, $2, $3,
  1494. (select "userId" from organization_members
  1495. where "organizationId" = $1 and role = 'owner' limit 1),
  1496. $1)
  1497. on conflict ("organizationId", key) do update set value = excluded.value`,
  1498. [organizationId, key, value]
  1499. )
  1500. return before.rows[0]?.value ?? null
  1501. })
  1502. }
  1503. /**
  1504. * A custom field on one kind of record, made here so the spec owns it.
  1505. *
  1506. * `name` is the key the app stores values under and `label` is what a person
  1507. * reads, so both are stamped: the point of the field is that its label shows
  1508. * up on the record, and a name left over from an earlier run would collide on
  1509. * `(organizationId, name, entityType)`.
  1510. */
  1511. export async function plantCustomField(
  1512. organizationId: string,
  1513. entityType: 'service_record' | 'quote',
  1514. name: string
  1515. ): Promise<string> {
  1516. return withDb(async (db) => {
  1517. const result = await db.query<{ id: string }>(
  1518. `insert into custom_field_definitions
  1519. (id, name, label, "fieldType", "entityType", "sortOrder", "isActive",
  1520. "createdAt", "updatedAt", "userId", "organizationId")
  1521. values (gen_random_uuid()::text, $2, $2, 'text', $3, 0, true, now(), now(),
  1522. (select "userId" from organization_members
  1523. where "organizationId" = $1 and role = 'owner' limit 1),
  1524. $1)
  1525. returning id`,
  1526. [organizationId, name, entityType]
  1527. )
  1528. return result.rows[0].id
  1529. })
  1530. }
  1531. /** Removes planted field definitions, and the values written into them. */
  1532. export async function deleteCustomFields(ids: string[]): Promise<void> {
  1533. if (ids.length === 0) return
  1534. await withDb(async (db) => {
  1535. await db.query(`delete from custom_field_values where "fieldId" = any($1::text[])`, [ids])
  1536. await db.query(`delete from custom_field_definitions where id = any($1::text[])`, [ids])
  1537. })
  1538. }
  1539. /**
  1540. * The value stored in one custom field for one record, or null.
  1541. *
  1542. * Read back to prove a save from the browser reached the database rather than
  1543. * only the input it was typed into.
  1544. */
  1545. export async function customFieldValue(fieldId: string, entityId: string): Promise<string | null> {
  1546. return withDb(async (db) => {
  1547. const result = await db.query<{ value: string }>(
  1548. `select value from custom_field_values where "fieldId" = $1 and "entityId" = $2`,
  1549. [fieldId, entityId]
  1550. )
  1551. return result.rows[0]?.value ?? null
  1552. })
  1553. }
  1554. /**
  1555. * A workshop's own role by name, as `createDefaultRoles` made it.
  1556. *
  1557. * The built-in Member role is what the product really hands somebody at the
  1558. * desk, so a spec about that role has to use that row rather than build an
  1559. * equivalent permission list by hand: a list assembled in the test would keep
  1560. * passing after the real role changed underneath it.
  1561. */
  1562. export async function roleIdNamed(organizationId: string, name: string): Promise<string | null> {
  1563. return withDb(async (db) => {
  1564. const result = await db.query<{ id: string }>(
  1565. `select id from roles where "organizationId" = $1 and name = $2 limit 1`,
  1566. [organizationId, name]
  1567. )
  1568. return result.rows[0]?.id ?? null
  1569. })
  1570. }
  1571. // ─── Condition map ───────────────────────────────────────────────────────────
  1572. /** Where a planted mark was drawn: a job's drop-off, or one inspection check. */
  1573. export type MarkSheet =
  1574. | { serviceRecordId: string }
  1575. | { inspectionId: string; inspectionItemId: string }
  1576. export interface PlantedMark {
  1577. view?: 'top' | 'left' | 'right' | 'front' | 'rear'
  1578. panel?: string
  1579. kind?: string
  1580. severity?: 'minor' | 'major'
  1581. note?: string
  1582. /** When it was recorded; an earlier visit's mark is planted in the past. */
  1583. recordedAt?: Date
  1584. bodyType?: string
  1585. }
  1586. /**
  1587. * A job on a vehicle of its own, drawn as a sedan. Each spec plants its own
  1588. * car, so marks from another spec or an earlier run are never on it.
  1589. */
  1590. export async function plantConditionJob(
  1591. organizationId: string,
  1592. userId: string,
  1593. title: string,
  1594. openedAt?: Date
  1595. ): Promise<{ serviceRecordId: string; vehicleId: string }> {
  1596. const job = await plantJob(organizationId, userId, title)
  1597. await withDb(async (db) => {
  1598. await db.query(`update vehicles set "bodyType" = 'sedan' where id = $1`, [job.vehicleId])
  1599. if (openedAt) {
  1600. await db.query(`update service_records set "createdAt" = $2 where id = $1`, [
  1601. job.serviceRecordId,
  1602. openedAt,
  1603. ])
  1604. }
  1605. })
  1606. return job
  1607. }
  1608. /**
  1609. * An inspection on the vehicle with one condition map check, from a template
  1610. * of its own (the seed makes none). With `serviceRecordId` the job is linked to
  1611. * it, the way "Start inspection" links them.
  1612. */
  1613. export async function plantConditionInspection(
  1614. organizationId: string,
  1615. vehicleId: string,
  1616. label: string,
  1617. options: {
  1618. serviceRecordId?: string
  1619. completed?: boolean
  1620. startedAt?: Date
  1621. /** A second condition map check after the first: a hand-back beside the check-in. */
  1622. secondCheck?: boolean
  1623. } = {}
  1624. ): Promise<{ inspectionId: string; inspectionItemId: string; secondItemId: string | null }> {
  1625. return withDb(async (db) => {
  1626. const id = () => randomBytes(12).toString('hex')
  1627. const templateId = id()
  1628. const sectionId = id()
  1629. const inspectionId = id()
  1630. const inspectionItemId = id()
  1631. await db.query(
  1632. `insert into inspection_templates (id, name, "organizationId", "updatedAt")
  1633. values ($1, $2, $3, now())`,
  1634. [templateId, `${label} checklist`, organizationId]
  1635. )
  1636. await db.query(
  1637. `insert into inspection_template_sections (id, name, "templateId") values ($1, 'Body', $2)`,
  1638. [sectionId, templateId]
  1639. )
  1640. await db.query(
  1641. `insert into inspection_template_items (id, name, "inputType", "sectionId")
  1642. values ($1, 'Condition map', 'condition_map', $2)`,
  1643. [id(), sectionId]
  1644. )
  1645. await db.query(
  1646. `insert into inspections (id, "vehicleId", "organizationId", "templateId", status, "completedAt", "createdAt", "updatedAt")
  1647. values ($1, $2, $3, $4, $5, $6, coalesce($7, now()), now())`,
  1648. [
  1649. inspectionId,
  1650. vehicleId,
  1651. organizationId,
  1652. templateId,
  1653. options.completed ? 'completed' : 'in_progress',
  1654. options.completed ? new Date() : null,
  1655. options.startedAt ?? null,
  1656. ]
  1657. )
  1658. await db.query(
  1659. `insert into inspection_items (id, "inspectionId", name, section, "inputType", condition, "sortOrder")
  1660. values ($1, $2, 'Condition map', 'Body', 'condition_map', 'ok', 0)`,
  1661. [inspectionItemId, inspectionId]
  1662. )
  1663. const secondItemId = options.secondCheck ? id() : null
  1664. if (secondItemId) {
  1665. await db.query(
  1666. `insert into inspection_items (id, "inspectionId", name, section, "inputType", condition, "sortOrder")
  1667. values ($1, $2, 'Hand-back map', 'Body', 'condition_map', 'ok', 1)`,
  1668. [secondItemId, inspectionId]
  1669. )
  1670. }
  1671. if (options.serviceRecordId) {
  1672. await db.query(`update service_records set "inspectionId" = $2 where id = $1`, [
  1673. options.serviceRecordId,
  1674. inspectionId,
  1675. ])
  1676. }
  1677. return { inspectionId, inspectionItemId, secondItemId }
  1678. })
  1679. }
  1680. /** One mark written straight into the table, as if drawn on that sheet. */
  1681. export async function plantConditionMark(
  1682. organizationId: string,
  1683. vehicleId: string,
  1684. sheet: MarkSheet,
  1685. mark: PlantedMark = {}
  1686. ): Promise<string> {
  1687. return withDb(async (db) => {
  1688. const markId = randomBytes(12).toString('hex')
  1689. const onJob = 'serviceRecordId' in sheet
  1690. await db.query(
  1691. `insert into condition_marks
  1692. (id, "organizationId", "vehicleId", "serviceRecordId", "inspectionId", "inspectionItemId",
  1693. "bodyType", view, panel, x, y, kind, severity, note, "recordedAt", "updatedAt")
  1694. values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0.5, 0.5, $10, $11, $12, coalesce($13, now()), now())`,
  1695. [
  1696. markId,
  1697. organizationId,
  1698. vehicleId,
  1699. onJob ? sheet.serviceRecordId : null,
  1700. onJob ? null : sheet.inspectionId,
  1701. onJob ? null : sheet.inspectionItemId,
  1702. mark.bodyType ?? 'sedan',
  1703. mark.view ?? 'left',
  1704. mark.panel ?? 'left_front_door',
  1705. mark.kind ?? 'dent',
  1706. mark.severity ?? 'minor',
  1707. mark.note ?? null,
  1708. mark.recordedAt ?? null,
  1709. ]
  1710. )
  1711. return markId
  1712. })
  1713. }
  1714. export interface ConditionMarkRow {
  1715. id: string
  1716. serviceRecordId: string | null
  1717. inspectionId: string | null
  1718. view: string
  1719. panel: string
  1720. kind: string
  1721. severity: string
  1722. note: string | null
  1723. imageUrls: string[]
  1724. resolvedAt: Date | null
  1725. }
  1726. /** Every mark on the vehicle, oldest first, resolved ones included. */
  1727. export async function conditionMarksOf(vehicleId: string): Promise<ConditionMarkRow[]> {
  1728. return withDb(async (db) => {
  1729. const result = await db.query<ConditionMarkRow>(
  1730. `select id, "serviceRecordId", "inspectionId", view, panel, kind, severity, note,
  1731. "imageUrls", "resolvedAt"
  1732. from condition_marks where "vehicleId" = $1 order by "recordedAt", "createdAt"`,
  1733. [vehicleId]
  1734. )
  1735. return result.rows
  1736. })
  1737. }
  1738. /** Whether the job's invoice was told to carry the map: true, false, or null to follow the design. */
  1739. export async function conditionMapOnInvoice(serviceRecordId: string): Promise<boolean | null> {
  1740. return withDb(async (db) => {
  1741. const result = await db.query<{ on: boolean | null }>(
  1742. `select "conditionMapOnInvoice" as on from service_records where id = $1`,
  1743. [serviceRecordId]
  1744. )
  1745. return result.rows[0]?.on ?? null
  1746. })
  1747. }
  1748. /** Another job on a car that already has one: the next visit. */
  1749. export async function plantJobOnVehicle(
  1750. organizationId: string,
  1751. vehicleId: string,
  1752. title: string,
  1753. openedAt?: Date
  1754. ): Promise<{ serviceRecordId: string; vehicleId: string }> {
  1755. return withDb(async (db) => {
  1756. const job = await db.query<{ id: string }>(
  1757. `insert into service_records (id, title, "vehicleId", "organizationId", "createdAt", "updatedAt")
  1758. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, coalesce($4, now()), now())
  1759. returning id`,
  1760. [title, vehicleId, organizationId, openedAt ?? null]
  1761. )
  1762. return { serviceRecordId: job.rows[0].id, vehicleId }
  1763. })
  1764. }
  1765. /** A kind of mark of the workshop's own, keyed the way the settings page keys one. */
  1766. export async function plantOwnMarkKind(
  1767. organizationId: string,
  1768. name: string
  1769. ): Promise<{ id: string; key: string; name: string }> {
  1770. return withDb(async (db) => {
  1771. const id = randomBytes(12).toString('hex')
  1772. const key = `own_${id}`
  1773. await db.query(
  1774. `insert into condition_mark_types (id, "organizationId", key, name, shape, color, "sortOrder", hidden, "updatedAt")
  1775. values ($1, $2, $3, $4, 'circle', '#7c3aed', 100, false, now())`,
  1776. [id, organizationId, key, name]
  1777. )
  1778. return { id, key, name }
  1779. })
  1780. }
  1781. export async function removeOwnMarkKind(id: string): Promise<void> {
  1782. await withDb((db) => db.query(`delete from condition_mark_types where id = $1`, [id]))
  1783. }
  1784. // ─── A quote added to an open work order ─────────────────────────────────────
  1785. /**
  1786. * The columns a spec may write on a quote or a work order to make the two
  1787. * disagree, without touching the workshop's own settings (which other specs
  1788. * read at the same time). The stored totals are columns too: a discount or a
  1789. * tax written here leaves them stale until the document is saved or
  1790. * re-totalled, so a spec writes them alongside when it reads them back.
  1791. */
  1792. export interface DocumentFields {
  1793. subtotal?: number
  1794. discountType?: 'percentage' | 'fixed' | null
  1795. discountValue?: number
  1796. discountAmount?: number
  1797. taxRate?: number
  1798. taxInclusive?: boolean
  1799. taxComponents?: { name: string; rate: number }[] | null
  1800. taxAmount?: number
  1801. totalAmount?: number
  1802. warrantyStatus?: 'included' | 'not_included' | null
  1803. warrantyMonths?: number | null
  1804. warrantyMileage?: number | null
  1805. warrantyNotes?: string | null
  1806. }
  1807. export interface ServiceRecordFields extends DocumentFields {
  1808. status?: string
  1809. }
  1810. const DOCUMENT_COLUMNS = new Set([
  1811. 'subtotal',
  1812. 'discountType',
  1813. 'discountValue',
  1814. 'discountAmount',
  1815. 'taxRate',
  1816. 'taxInclusive',
  1817. 'taxComponents',
  1818. 'taxAmount',
  1819. 'totalAmount',
  1820. 'warrantyStatus',
  1821. 'warrantyMonths',
  1822. 'warrantyMileage',
  1823. 'warrantyNotes',
  1824. 'status',
  1825. ])
  1826. /** `"col" = $n, ...` for the given fields, refusing anything not listed above. */
  1827. function assignments(fields: object, offset: number): { sql: string; values: unknown[] } {
  1828. const entries = Object.entries(fields).filter(([, value]) => value !== undefined)
  1829. if (entries.length === 0) throw new Error('nothing to write')
  1830. for (const [column] of entries) {
  1831. if (!DOCUMENT_COLUMNS.has(column)) throw new Error(`not a writable column: ${column}`)
  1832. }
  1833. return {
  1834. sql: entries.map(([column], i) => `"${column}" = $${i + offset}`).join(', '),
  1835. values: entries.map(([column, value]) =>
  1836. column === 'taxComponents' && value !== null ? JSON.stringify(value) : value
  1837. ),
  1838. }
  1839. }
  1840. /** Writes columns on a quote, found by its id or its number. */
  1841. export async function setQuoteFields(
  1842. quoteNumberOrId: string,
  1843. fields: DocumentFields
  1844. ): Promise<void> {
  1845. await withDb(async (db) => {
  1846. const { sql, values } = assignments(fields, 2)
  1847. const result = await db.query(
  1848. `update quotes set ${sql}, "updatedAt" = now() where id = $1 or "quoteNumber" = $1`,
  1849. [quoteNumberOrId, ...values]
  1850. )
  1851. if (result.rowCount !== 1) {
  1852. throw new Error(`expected one quote for ${quoteNumberOrId}, found ${result.rowCount}`)
  1853. }
  1854. })
  1855. }
  1856. /** Writes columns on a work order. */
  1857. export async function setServiceRecordFields(
  1858. serviceRecordId: string,
  1859. fields: ServiceRecordFields
  1860. ): Promise<void> {
  1861. await withDb(async (db) => {
  1862. const { sql, values } = assignments(fields, 2)
  1863. const result = await db.query(
  1864. `update service_records set ${sql}, "updatedAt" = now() where id = $1`,
  1865. [serviceRecordId, ...values]
  1866. )
  1867. if (result.rowCount !== 1) throw new Error(`no work order ${serviceRecordId}`)
  1868. })
  1869. }
  1870. export interface PlantedPartLine {
  1871. name: string
  1872. quantity: number
  1873. unitPrice: number
  1874. }
  1875. export interface PlantedLaborLine {
  1876. description: string
  1877. hours: number
  1878. rate: number
  1879. }
  1880. /**
  1881. * Part and labour lines written onto a work order, priced the way the editor
  1882. * prices them (quantity × unit price, hours × rate). Nothing is re-totalled:
  1883. * the spec writes the stored totals it wants with `setServiceRecordFields`.
  1884. */
  1885. export async function addServiceRecordLines(
  1886. serviceRecordId: string,
  1887. lines: { parts?: PlantedPartLine[]; labor?: PlantedLaborLine[] }
  1888. ): Promise<void> {
  1889. await withDb(async (db) => {
  1890. for (const part of lines.parts ?? []) {
  1891. await db.query(
  1892. `insert into service_parts (id, name, quantity, "unitPrice", total, "serviceRecordId")
  1893. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
  1894. [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, serviceRecordId]
  1895. )
  1896. }
  1897. for (const labor of lines.labor ?? []) {
  1898. await db.query(
  1899. `insert into service_labor (id, description, hours, rate, total, "serviceRecordId")
  1900. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
  1901. [labor.description, labor.hours, labor.rate, labor.hours * labor.rate, serviceRecordId]
  1902. )
  1903. }
  1904. })
  1905. }
  1906. /**
  1907. * A draft quote for a vehicle and its owner, with its lines, as if typed into
  1908. * the editor and saved. Its tax, discount and warranty are whatever the spec
  1909. * writes next with `setQuoteFields`; the stored subtotal and total follow the
  1910. * lines here so the quote is consistent before that.
  1911. */
  1912. export async function plantQuote(
  1913. organizationId: string,
  1914. userId: string,
  1915. input: {
  1916. vehicleId: string
  1917. title: string
  1918. parts?: PlantedPartLine[]
  1919. labor?: PlantedLaborLine[]
  1920. }
  1921. ): Promise<string> {
  1922. return withDb(async (db) => {
  1923. const parts = input.parts ?? []
  1924. const labor = input.labor ?? []
  1925. const subtotal =
  1926. parts.reduce((sum, p) => sum + p.quantity * p.unitPrice, 0) +
  1927. labor.reduce((sum, l) => sum + l.hours * l.rate, 0)
  1928. const quote = await db.query<{ id: string }>(
  1929. `insert into quotes (id, title, status, subtotal, "totalAmount", "userId", "organizationId",
  1930. "customerId", "vehicleId", "updatedAt")
  1931. values (md5(random()::text || clock_timestamp()::text), $1, 'draft', $2, $2, $3, $4,
  1932. (select "customerId" from vehicles where id = $5), $5, now())
  1933. returning id`,
  1934. [input.title, subtotal, userId, organizationId, input.vehicleId]
  1935. )
  1936. const quoteId = quote.rows[0].id
  1937. for (const part of parts) {
  1938. await db.query(
  1939. `insert into quote_parts (id, name, quantity, "unitPrice", total, "quoteId")
  1940. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
  1941. [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, quoteId]
  1942. )
  1943. }
  1944. for (const line of labor) {
  1945. await db.query(
  1946. `insert into quote_labor (id, description, hours, rate, total, "quoteId")
  1947. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
  1948. [line.description, line.hours, line.rate, line.hours * line.rate, quoteId]
  1949. )
  1950. }
  1951. return quoteId
  1952. })
  1953. }
  1954. export interface StoredServiceRecord {
  1955. status: string
  1956. subtotal: number
  1957. discountType: string | null
  1958. discountValue: number
  1959. discountAmount: number
  1960. taxRate: number
  1961. taxInclusive: boolean
  1962. taxAmount: number
  1963. totalAmount: number
  1964. warrantyStatus: string | null
  1965. warrantyMonths: number | null
  1966. warrantyExpiresAt: Date | null
  1967. }
  1968. /** What a work order stores about its money and its warranty. */
  1969. export async function storedServiceRecord(serviceRecordId: string): Promise<StoredServiceRecord> {
  1970. return withDb(async (db) => {
  1971. const result = await db.query<StoredServiceRecord>(
  1972. `select status, subtotal, "discountType", "discountValue", "discountAmount", "taxRate",
  1973. "taxInclusive", "taxAmount", "totalAmount", "warrantyStatus", "warrantyMonths",
  1974. "warrantyExpiresAt"
  1975. from service_records where id = $1`,
  1976. [serviceRecordId]
  1977. )
  1978. const row = result.rows[0]
  1979. if (!row) throw new Error(`no work order ${serviceRecordId}`)
  1980. return row
  1981. })
  1982. }
  1983. /** The work order's part lines, by name. */
  1984. export async function servicePartLines(
  1985. serviceRecordId: string
  1986. ): Promise<{ name: string; quantity: number; unitPrice: number; total: number }[]> {
  1987. return withDb(async (db) => {
  1988. const result = await db.query<{
  1989. name: string
  1990. quantity: number
  1991. unitPrice: number
  1992. total: number
  1993. }>(
  1994. `select name, quantity, "unitPrice", total from service_parts
  1995. where "serviceRecordId" = $1 order by name`,
  1996. [serviceRecordId]
  1997. )
  1998. return result.rows
  1999. })
  2000. }
  2001. /** A quote's status and the job it went to, if any. */
  2002. export async function quoteConversion(
  2003. quoteId: string
  2004. ): Promise<{ status: string; convertedToId: string | null }> {
  2005. return withDb(async (db) => {
  2006. const result = await db.query<{ status: string; convertedToId: string | null }>(
  2007. `select status, "convertedToId" from quotes where id = $1`,
  2008. [quoteId]
  2009. )
  2010. const row = result.rows[0]
  2011. if (!row) throw new Error(`no quote ${quoteId}`)
  2012. return row
  2013. })
  2014. }
  2015. /** A technician's time off as stored, soonest first. */
  2016. export async function absencesOf(
  2017. technicianId: string
  2018. ): Promise<{ startDate: string; endDate: string; reason: string; startTime: string | null }[]> {
  2019. return withDb(async (db) => {
  2020. const result = await db.query(
  2021. `select to_char("startDate", 'YYYY-MM-DD') as "startDate",
  2022. to_char("endDate", 'YYYY-MM-DD') as "endDate", reason, "startTime"
  2023. from technician_absences where "technicianId" = $1 order by "startDate"`,
  2024. [technicianId]
  2025. )
  2026. return result.rows
  2027. })
  2028. }
  2029. /** The workshop's own closed days with this name, as stored. */
  2030. export async function closuresNamed(
  2031. name: string
  2032. ): Promise<{ startDate: string; endDate: string; repeatsYearly: boolean }[]> {
  2033. return withDb(async (db) => {
  2034. const result = await db.query(
  2035. `select to_char("startDate", 'YYYY-MM-DD') as "startDate",
  2036. to_char("endDate", 'YYYY-MM-DD') as "endDate", "repeatsYearly"
  2037. from workshop_closures where name = $1`,
  2038. [name]
  2039. )
  2040. return result.rows
  2041. })
  2042. }
  2043. export async function deleteClosuresNamed(name: string): Promise<void> {
  2044. await withDb((db) => db.query(`delete from workshop_closures where name = $1`, [name]))
  2045. }