db.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337
  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. export async function seededTenantFixtures(): Promise<TenantFixtures> {
  122. const organizationId = await ownerOrganizationId()
  123. return withDb(async (db) => {
  124. const one = async (sql: string): Promise<string> => {
  125. const result = await db.query<{ id: string }>(sql, [organizationId])
  126. const id = result.rows[0]?.id
  127. if (!id) throw new Error(`the seeded workshop has nothing for: ${sql}`)
  128. return id
  129. }
  130. /**
  131. * A vehicle and one of its own jobs, from one row.
  132. *
  133. * Two queries answered this before, and on a database the suite had been
  134. * run against they happened to agree. On a fresh seed they did not, and
  135. * the job of one vehicle opened under the id of another draws a page with
  136. * nothing on it.
  137. *
  138. * The organisation comes off the vehicle: `service_records.organizationId`
  139. * is nullable and the seed leaves it null, scoping a job by the vehicle it
  140. * sits on.
  141. */
  142. const pair = await db.query<{
  143. vehicleId: string
  144. serviceRecordId: string
  145. licensePlate: string
  146. }>(
  147. `select v.id as "vehicleId", s.id as "serviceRecordId", v."licensePlate"
  148. from service_records s
  149. join vehicles v on v.id = s."vehicleId"
  150. where coalesce(s."organizationId", v."organizationId") = $1
  151. and v."licensePlate" is not null and v."licensePlate" <> ''
  152. order by s."createdAt"
  153. limit 1`,
  154. [organizationId]
  155. )
  156. const job = pair.rows[0]
  157. if (!job) throw new Error('the seeded workshop has no work order on a plated vehicle')
  158. return {
  159. organizationId,
  160. vehicleId: job.vehicleId,
  161. serviceRecordId: job.serviceRecordId,
  162. vehiclePlate: job.licensePlate,
  163. customerId: await one(`select id from customers where "organizationId" = $1 limit 1`),
  164. quoteId: await one(
  165. `select id from quotes
  166. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  167. limit 1`
  168. ),
  169. customerName: await one(
  170. `select name as id from customers where "organizationId" = $1 limit 1`
  171. ),
  172. quoteNumber: await one(
  173. `select "quoteNumber" as id from quotes
  174. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  175. limit 1`
  176. ),
  177. }
  178. })
  179. }
  180. /**
  181. * Any work order belonging to a given workshop, for the tests that point one
  182. * workshop's credential at another's records. A workshop that has just been
  183. * opened has a few of its own from onboarding, which is what makes a
  184. * freshly signed-up account a usable target.
  185. */
  186. export async function foreignServiceRecordId(organizationId: string): Promise<string> {
  187. return withDb(async (db) => {
  188. const result = await db.query<{ id: string }>(
  189. `select s.id
  190. from service_records s
  191. join vehicles v on v.id = s."vehicleId"
  192. where coalesce(s."organizationId", v."organizationId") = $1
  193. order by s."createdAt"
  194. limit 1`,
  195. [organizationId]
  196. )
  197. const id = result.rows[0]?.id
  198. if (!id) throw new Error(`no work order in organization ${organizationId}`)
  199. return id
  200. })
  201. }
  202. /** The stored (encrypted) TOTP secret of a user, or null when 2FA is not set up. */
  203. export async function storedTwoFactorSecret(email: string): Promise<string | null> {
  204. return withDb(async (db) => {
  205. const result = await db.query<{ secret: string }>(
  206. `select tf.secret from two_factor tf join users u on u.id = tf."userId" where u.email = $1`,
  207. [email]
  208. )
  209. return result.rows[0]?.secret ?? null
  210. })
  211. }
  212. export interface StockedPart {
  213. id: string
  214. name: string
  215. /** What the ledger says it has on hand right now. */
  216. quantity: number
  217. }
  218. /**
  219. * A seeded inventory part with enough on hand to be consumed by a job, and
  220. * whose name is distinctive enough to search for in the picker.
  221. *
  222. * The part is chosen rather than created, because what is under test is the
  223. * path a workshop actually walks: pick a stocked part, use it, and watch the
  224. * count fall.
  225. */
  226. export async function stockedPart(organizationId: string, atLeast = 10): Promise<StockedPart> {
  227. return withDb(async (db) => {
  228. const result = await db.query<StockedPart>(
  229. `select id, name, quantity
  230. from inventory_parts
  231. where "organizationId" = $1 and quantity >= $2
  232. order by quantity desc, name
  233. limit 1`,
  234. [organizationId, atLeast]
  235. )
  236. const part = result.rows[0]
  237. if (!part) throw new Error(`no inventory part with ${atLeast} or more on hand`)
  238. return { ...part, quantity: Number(part.quantity) }
  239. })
  240. }
  241. /** What one inventory part has on hand. */
  242. export async function partQuantity(partId: string): Promise<number> {
  243. return withDb(async (db) => {
  244. const result = await db.query<{ quantity: number }>(
  245. `select quantity from inventory_parts where id = $1`,
  246. [partId]
  247. )
  248. if (!result.rows[0]) throw new Error(`no inventory part ${partId}`)
  249. return Number(result.rows[0].quantity)
  250. })
  251. }
  252. /** Set a part's quantity outright, to put the seed back as it was found. */
  253. export async function setPartQuantity(partId: string, quantity: number): Promise<void> {
  254. await withDb((db) =>
  255. db.query(`update inventory_parts set quantity = $2 where id = $1`, [partId, quantity])
  256. )
  257. }
  258. export interface StockMovement {
  259. delta: number
  260. quantityAfter: number
  261. reason: string
  262. serviceRecordId: string | null
  263. }
  264. /**
  265. * The ledger for one part, oldest first. Every movement is a row: the count on
  266. * the part is only ever the running total of these, which is why a spec that
  267. * checks stock checks both.
  268. */
  269. export async function stockMovements(
  270. partId: string,
  271. serviceRecordId?: string
  272. ): Promise<StockMovement[]> {
  273. return withDb(async (db) => {
  274. const result = await db.query<StockMovement>(
  275. `select delta, "quantityAfter", reason, "serviceRecordId"
  276. from stock_movements
  277. where "inventoryPartId" = $1
  278. and ($2::text is null or "serviceRecordId" = $2)
  279. order by "createdAt", id`,
  280. [partId, serviceRecordId ?? null]
  281. )
  282. return result.rows.map((row) => ({
  283. ...row,
  284. delta: Number(row.delta),
  285. quantityAfter: Number(row.quantityAfter),
  286. }))
  287. })
  288. }
  289. /** When a reminder is due, as the instant that was stored for it. */
  290. export async function reminderDueDate(title: string): Promise<Date> {
  291. return withDb(async (db) => {
  292. const result = await db.query<{ dueDate: Date }>(
  293. `select "dueDate" from reminders where title = $1 order by "createdAt" desc limit 1`,
  294. [title]
  295. )
  296. const due = result.rows[0]?.dueDate
  297. if (!due) throw new Error(`no reminder titled "${title}" with a due date`)
  298. return new Date(due)
  299. })
  300. }
  301. /** Removes the reminders a spec made, whatever state the page was left in. */
  302. export async function deleteRemindersTitled(title: string): Promise<void> {
  303. await withDb((db) => db.query(`delete from reminders where title = $1`, [title]))
  304. }
  305. /**
  306. * The newest file on a work order, as the app stored its address.
  307. *
  308. * A spec that needs a file belonging to one workshop uploads one and reads it
  309. * back here. Looking for a seeded one instead only worked on a database the
  310. * attachment spec had already run against.
  311. */
  312. export async function latestAttachmentUrl(serviceRecordId: string): Promise<string> {
  313. return withDb(async (db) => {
  314. const result = await db.query<{ fileUrl: string }>(
  315. `select "fileUrl" from service_attachments
  316. where "serviceRecordId" = $1 and "fileUrl" like '/api/protected/files/%'
  317. order by "createdAt" desc
  318. limit 1`,
  319. [serviceRecordId]
  320. )
  321. const url = result.rows[0]?.fileUrl
  322. if (!url) throw new Error(`no stored file on work order ${serviceRecordId}`)
  323. return url
  324. })
  325. }
  326. /**
  327. * Backdates a job's scheduled start to an hour ago. A new work order is
  328. * booked into the shop's next free slot, often tomorrow, and the financial
  329. * reports run up to the present moment, so a job made by a spec is not in
  330. * this year's tax report until it is moved into the past.
  331. */
  332. export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
  333. await withDb((db) =>
  334. db.query(
  335. `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
  336. [serviceRecordId]
  337. )
  338. )
  339. }
  340. /**
  341. * Email templates a spec made, gone again, and every kind back on its
  342. * built-in preset.
  343. *
  344. * The gallery's own delete is what a workshop uses and one test walks it, but
  345. * a file that fails halfway must not leave the workshop sending mail designed
  346. * by a test: the pointer is an `email.template.<kind>` setting, and a
  347. * template row it names is what the resolver prefers over the preset.
  348. */
  349. export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
  350. await withDb(async (db) => {
  351. await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
  352. await db.query(
  353. `delete from app_settings
  354. where key like 'email.template.%'
  355. and value not in (select 'design:' || id from email_templates)`
  356. )
  357. })
  358. }
  359. /** The names of the templates saved for one kind of mail. */
  360. export async function emailTemplateNames(kind: string): Promise<string[]> {
  361. return withDb(async (db) => {
  362. const result = await db.query<{ name: string }>(
  363. `select name from email_templates where kind = $1 order by "createdAt"`,
  364. [kind]
  365. )
  366. return result.rows.map((row) => row.name)
  367. })
  368. }
  369. /**
  370. * Whose car it is, and where to write to them.
  371. *
  372. * The customer of the vehicle a spec is working on, not the first customer in
  373. * the workshop: a message sent from a job goes to the owner of that car, so a
  374. * spec waiting on another customer's mailbox waits forever.
  375. */
  376. export async function customerOfVehicle(
  377. vehicleId: string
  378. ): Promise<{ name: string; email: string }> {
  379. return withDb(async (db) => {
  380. const result = await db.query<{ name: string; email: string }>(
  381. `select c.name, c.email
  382. from vehicles v
  383. join customers c on c.id = v."customerId"
  384. where v.id = $1`,
  385. [vehicleId]
  386. )
  387. const customer = result.rows[0]
  388. if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
  389. return customer
  390. })
  391. }
  392. /** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
  393. export async function connectionStatus(connectorId: string): Promise<string | null> {
  394. const organizationId = await ownerOrganizationId()
  395. return withDb(async (db) => {
  396. const result = await db.query<{ status: string }>(
  397. `select status from integration_connections
  398. where "organizationId" = $1 and "connectorId" = $2`,
  399. [organizationId, connectorId]
  400. )
  401. return result.rows[0]?.status ?? null
  402. })
  403. }
  404. /**
  405. * Every connection a spec made to a vendor, gone. The payment specs connect
  406. * Stripe and PayPal to the seeded workshop, and a connection left behind puts
  407. * pay buttons on every invoice the rest of the suite shares.
  408. */
  409. export async function forgetConnections(connectorIds: string[]): Promise<void> {
  410. const organizationId = await ownerOrganizationId()
  411. await withDb((db) =>
  412. db.query(
  413. `delete from integration_connections
  414. where "organizationId" = $1 and "connectorId" = any($2::text[])`,
  415. [organizationId, connectorIds]
  416. )
  417. )
  418. }
  419. export interface RecordedPayment {
  420. amount: number
  421. provider: string | null
  422. method: string
  423. externalId: string | null
  424. }
  425. /** The money recorded against one work order, oldest first. */
  426. export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
  427. return withDb(async (db) => {
  428. const result = await db.query<RecordedPayment>(
  429. `select amount, provider, method, "externalId" from payments
  430. where "serviceRecordId" = $1
  431. order by "createdAt", id`,
  432. [serviceRecordId]
  433. )
  434. return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
  435. })
  436. }
  437. /**
  438. * Writes a vendor payment row straight into the table, bypassing the app.
  439. *
  440. * For the one question only the database can answer: whether it refuses a
  441. * second row for a payment it already holds. Returns the Postgres error code
  442. * when the insert is refused, or null when it went in.
  443. */
  444. export async function insertVendorPaymentRow(row: {
  445. serviceRecordId: string
  446. provider: string
  447. externalId: string
  448. amount: number
  449. }): Promise<string | null> {
  450. return withDb(async (db) => {
  451. try {
  452. await db.query(
  453. `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
  454. values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
  455. [row.amount, row.provider, row.externalId, row.serviceRecordId]
  456. )
  457. return null
  458. } catch (error) {
  459. return (error as { code?: string }).code ?? 'unknown'
  460. }
  461. })
  462. }
  463. /**
  464. * How many rows one invoice holds for one vendor payment.
  465. *
  466. * Counted against the invoice as well as the id: a vendor's id means one
  467. * payment on one invoice, and a count across the whole table also finds any
  468. * other invoice that happens to carry the same id, which is not a duplicate.
  469. */
  470. export async function vendorPaymentRows(
  471. serviceRecordId: string,
  472. externalId: string
  473. ): Promise<number> {
  474. return withDb(async (db) => {
  475. const result = await db.query<{ n: number }>(
  476. `select count(*)::int as n from payments
  477. where "serviceRecordId" = $1 and "externalId" = $2`,
  478. [serviceRecordId, externalId]
  479. )
  480. return result.rows[0]?.n ?? 0
  481. })
  482. }
  483. /** Removes the rows a spec wrote for one vendor payment. */
  484. export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
  485. await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
  486. }
  487. /** The id of the user signed up with an address. */
  488. export async function userIdFor(email: string): Promise<string> {
  489. return withDb(async (db) => {
  490. const result = await db.query<{ id: string }>(
  491. `select id from users where lower(email) = lower($1)`,
  492. [email]
  493. )
  494. const id = result.rows[0]?.id
  495. if (!id) throw new Error(`no user with ${email}`)
  496. return id
  497. })
  498. }
  499. /**
  500. * Writes customers straight into a workshop, as if it had typed them in.
  501. *
  502. * For reaching a plan limit without twenty trips through a form: what is
  503. * under test is the one customer past the limit, and that one goes through
  504. * the app. These are real customers, not sample ones, so they count.
  505. */
  506. export async function insertCustomers(
  507. organizationId: string,
  508. userId: string,
  509. count: number,
  510. prefix: string
  511. ): Promise<void> {
  512. await withDb((db) =>
  513. db.query(
  514. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  515. select md5(random()::text || clock_timestamp()::text || n), $3 || ' ' || n, $2, $1, now()
  516. from generate_series(1, $4::int) as n`,
  517. [organizationId, userId, prefix, count]
  518. )
  519. )
  520. }
  521. /** Every customer row a workshop holds, sample ones included. */
  522. export async function customerRows(organizationId: string): Promise<number> {
  523. return withDb(async (db) => {
  524. const result = await db.query<{ n: number }>(
  525. `select count(*)::int as n from customers where "organizationId" = $1`,
  526. [organizationId]
  527. )
  528. return result.rows[0]?.n ?? 0
  529. })
  530. }
  531. /** Team invitations a workshop has sent. */
  532. export async function teamInvitations(organizationId: string): Promise<number> {
  533. return withDb(async (db) => {
  534. const result = await db.query<{ n: number }>(
  535. `select count(*)::int as n from team_invitations where "organizationId" = $1`,
  536. [organizationId]
  537. )
  538. return result.rows[0]?.n ?? 0
  539. })
  540. }
  541. /**
  542. * Puts a workshop on an active Pro subscription, as a paid checkout would.
  543. * Returns the plan's id so the spec can take it away again.
  544. */
  545. export async function giveProPlan(
  546. organizationId: string,
  547. stripe?: { subscriptionId: string; customerId: string }
  548. ): Promise<string> {
  549. return withDb(async (db) => {
  550. const plan = await db.query<{ id: string }>(
  551. `insert into subscription_plans (id, name, price, "updatedAt")
  552. values (md5(random()::text || clock_timestamp()::text), 'E2E Pro', 0, now())
  553. returning id`
  554. )
  555. const planId = plan.rows[0].id
  556. // With Stripe ids the row looks like a real purchase, which is what the
  557. // manage-subscription card and its buttons are shown for.
  558. await db.query(
  559. `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt",
  560. "stripeSubscriptionId", "stripeCustomerId")
  561. values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now(), $3, $4)`,
  562. [organizationId, planId, stripe?.subscriptionId ?? null, stripe?.customerId ?? null]
  563. )
  564. return planId
  565. })
  566. }
  567. /** Flags a subscription as ending at the period end, as a cancel through torqvoice.com would. */
  568. export async function setCancelAtPeriodEnd(organizationId: string, value: boolean): Promise<void> {
  569. await withDb((db) =>
  570. db.query(`update subscriptions set "cancelAtPeriodEnd" = $2 where "organizationId" = $1`, [
  571. organizationId,
  572. value,
  573. ])
  574. )
  575. }
  576. /** Takes a subscription and its plan away again. */
  577. export async function removePlan(organizationId: string, planId: string): Promise<void> {
  578. await withDb(async (db) => {
  579. await db.query(`delete from subscriptions where "organizationId" = $1`, [organizationId])
  580. await db.query(`delete from subscription_plans where id = $1`, [planId])
  581. })
  582. }
  583. export interface PersonRecord {
  584. /** How many users hold the address: more than one is two people where there should be one. */
  585. users: number
  586. /** How each of them can sign in: `credential` for a password, `google`. */
  587. providers: string[]
  588. emailVerified: boolean
  589. }
  590. /** Who holds an address, and how they can sign in. */
  591. export async function personWithEmail(email: string): Promise<PersonRecord> {
  592. return withDb(async (db) => {
  593. const users = await db.query<{ id: string; emailVerified: boolean }>(
  594. `select id, "emailVerified" from users where lower(email) = lower($1)`,
  595. [email]
  596. )
  597. const providers = await db.query<{ providerId: string }>(
  598. `select a."providerId" from accounts a join users u on u.id = a."userId"
  599. where lower(u.email) = lower($1) order by a."providerId"`,
  600. [email]
  601. )
  602. return {
  603. users: users.rows.length,
  604. providers: providers.rows.map((row) => row.providerId),
  605. emailVerified: users.rows.some((row) => row.emailVerified),
  606. }
  607. })
  608. }
  609. /** Every vehicle row a workshop holds. */
  610. export async function vehicleRows(organizationId: string): Promise<number> {
  611. return withDb(async (db) => {
  612. const result = await db.query<{ n: number }>(
  613. `select count(*)::int as n from vehicles where "organizationId" = $1`,
  614. [organizationId]
  615. )
  616. return result.rows[0]?.n ?? 0
  617. })
  618. }
  619. /** One of a workshop's settings as stored, or null when it was never saved. */
  620. export async function workshopSetting(organizationId: string, key: string): Promise<string | null> {
  621. return withDb(async (db) => {
  622. const result = await db.query<{ value: string }>(
  623. `select value from app_settings where "organizationId" = $1 and key = $2`,
  624. [organizationId, key]
  625. )
  626. return result.rows[0]?.value ?? null
  627. })
  628. }
  629. /**
  630. * A vehicle registry connected to a workshop, active, the way the header's
  631. * plate lookup looks for one. No keys: nothing is looked up, only offered.
  632. */
  633. export async function connectRegistry(
  634. organizationId: string,
  635. userId: string,
  636. connectorId: string
  637. ): Promise<void> {
  638. await withDb((db) =>
  639. db.query(
  640. `insert into integration_connections
  641. (id, "organizationId", "connectorId", status, "createdById", "updatedAt")
  642. values ($1, $2, $3, 'active', $4, now())`,
  643. [`e2e-${connectorId}-${Date.now()}`, organizationId, connectorId, userId]
  644. )
  645. )
  646. }
  647. export async function disconnectRegistry(
  648. organizationId: string,
  649. connectorId: string
  650. ): Promise<void> {
  651. await withDb((db) =>
  652. db.query(
  653. `delete from integration_connections where "organizationId" = $1 and "connectorId" = $2`,
  654. [organizationId, connectorId]
  655. )
  656. )
  657. }
  658. /** The id of a workshop's customer with exactly this name. */
  659. export async function customerIdNamed(organizationId: string, name: string): Promise<string> {
  660. return withDb(async (db) => {
  661. const result = await db.query<{ id: string }>(
  662. `select id from customers where "organizationId" = $1 and name = $2`,
  663. [organizationId, name]
  664. )
  665. const id = result.rows[0]?.id
  666. if (!id) throw new Error(`no customer named ${name}`)
  667. return id
  668. })
  669. }
  670. // ─── The security specs ──────────────────────────────────────────────────────
  671. /**
  672. * A custom role carrying every action on every subject the app knows, and no
  673. * admin standing. It is the sharpest test of "logged in is not allowed": a
  674. * member with this role passes every `requiredPermissions` check there is,
  675. * and the owner-only and admin-only actions have to refuse them anyway.
  676. */
  677. export async function createRoleWithEveryPermission(
  678. organizationId: string,
  679. name: string
  680. ): Promise<string> {
  681. const subjects = [
  682. 'dashboard',
  683. 'vehicles',
  684. 'customers',
  685. 'work_orders',
  686. 'quotes',
  687. 'services',
  688. 'billing',
  689. 'inventory',
  690. 'labor_presets',
  691. 'inspections',
  692. 'tire_hotel',
  693. 'reports',
  694. 'settings',
  695. 'work_board',
  696. 'ai_assistant',
  697. 'time_tracking',
  698. ]
  699. const actions = ['create', 'read', 'update', 'delete', 'manage']
  700. return withDb(async (db) => {
  701. const role = await db.query<{ id: string }>(
  702. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  703. values (gen_random_uuid()::text, $1, false, $2, now(), now())
  704. returning id`,
  705. [name, organizationId]
  706. )
  707. const roleId = role.rows[0].id
  708. for (const subject of subjects) {
  709. for (const action of actions) {
  710. await db.query(
  711. `insert into permissions (id, action, subject, "roleId")
  712. values (gen_random_uuid()::text, $1, $2, $3)`,
  713. [action, subject, roleId]
  714. )
  715. }
  716. }
  717. return roleId
  718. })
  719. }
  720. /** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
  721. export async function setMembership(
  722. email: string,
  723. organizationId: string,
  724. membership: { roleId: string | null; role: 'member' | 'admin' }
  725. ): Promise<void> {
  726. await withDb((db) =>
  727. db.query(
  728. `update organization_members m
  729. set "roleId" = $3, role = $4
  730. from users u
  731. where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
  732. [email, organizationId, membership.roleId, membership.role]
  733. )
  734. )
  735. }
  736. /** The credential in a pending invitation, or null when there is none for the address. */
  737. export async function invitationTokenFor(
  738. email: string,
  739. organizationId: string
  740. ): Promise<string | null> {
  741. return withDb(async (db) => {
  742. const result = await db.query<{ token: string }>(
  743. `select token from team_invitations
  744. where email = $1 and "organizationId" = $2 and status = 'pending'`,
  745. [email, organizationId]
  746. )
  747. return result.rows[0]?.token ?? null
  748. })
  749. }
  750. /** How much of the workshop there is, for a test that must find it all still there. */
  751. export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
  752. return withDb(async (db) => {
  753. const counts: Record<string, number> = {}
  754. for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
  755. const result = await db.query<{ n: string }>(
  756. `select count(*)::text as n from ${table} where "organizationId" = $1`,
  757. [organizationId]
  758. )
  759. counts[table] = Number(result.rows[0].n)
  760. }
  761. return counts
  762. })
  763. }
  764. /**
  765. * A file row written straight to the job, bypassing the schema that guards
  766. * the action: what a record carried before the guard existed, or what a
  767. * restore could bring in. The path resolver is the last line for these.
  768. */
  769. export async function insertServiceAttachment(row: {
  770. serviceRecordId: string
  771. fileName: string
  772. fileUrl: string
  773. fileType: string
  774. }): Promise<string> {
  775. return withDb(async (db) => {
  776. const result = await db.query<{ id: string }>(
  777. `insert into service_attachments
  778. (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
  779. values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
  780. returning id`,
  781. [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
  782. )
  783. return result.rows[0].id
  784. })
  785. }
  786. export async function deleteServiceAttachments(ids: string[]): Promise<void> {
  787. await withDb((db) =>
  788. db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
  789. )
  790. }
  791. /** How many file rows carry a name, on any job. */
  792. export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
  793. return withDb(async (db) => {
  794. const result = await db.query<{ n: string }>(
  795. `select count(*)::text as n from service_attachments where "fileName" = $1`,
  796. [fileName]
  797. )
  798. return Number(result.rows[0].n)
  799. })
  800. }
  801. /** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
  802. export async function insertConnection(row: {
  803. organizationId: string
  804. connectorId: string
  805. credentials: string
  806. settings: Record<string, unknown>
  807. createdById: string
  808. }): Promise<string> {
  809. return withDb(async (db) => {
  810. const result = await db.query<{ id: string }>(
  811. `insert into integration_connections
  812. (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
  813. values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
  814. returning id`,
  815. [
  816. row.organizationId,
  817. row.connectorId,
  818. row.credentials,
  819. JSON.stringify(row.settings),
  820. row.createdById,
  821. ]
  822. )
  823. return result.rows[0].id
  824. })
  825. }
  826. /** Inbound text messages with exactly this body, for a workshop. */
  827. export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
  828. return withDb(async (db) => {
  829. const result = await db.query<{ n: string }>(
  830. `select count(*)::text as n from sms_messages
  831. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  832. [organizationId, body]
  833. )
  834. return Number(result.rows[0].n)
  835. })
  836. }
  837. export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
  838. await withDb((db) =>
  839. db.query(
  840. `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  841. [organizationId, body]
  842. )
  843. )
  844. }
  845. // ─── Work order titles ───────────────────────────────────────────────────────
  846. /** What a job is called and numbered, straight from its row. */
  847. export async function serviceRecordNames(
  848. serviceRecordId: string
  849. ): Promise<{ title: string; invoiceNumber: string | null }> {
  850. return withDb(async (db) => {
  851. const result = await db.query<{ title: string; invoiceNumber: string | null }>(
  852. `select title, "invoiceNumber" from service_records where id = $1`,
  853. [serviceRecordId]
  854. )
  855. const row = result.rows[0]
  856. if (!row) throw new Error(`no work order ${serviceRecordId}`)
  857. return row
  858. })
  859. }
  860. /** The words a title template can print about one vehicle and its owner. */
  861. export async function vehicleFacts(vehicleId: string): Promise<{
  862. licensePlate: string | null
  863. make: string
  864. model: string
  865. year: number
  866. vin: string | null
  867. customerName: string | null
  868. }> {
  869. return withDb(async (db) => {
  870. const result = await db.query<{
  871. licensePlate: string | null
  872. make: string
  873. model: string
  874. year: number
  875. vin: string | null
  876. customerName: string | null
  877. }>(
  878. `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
  879. from vehicles v
  880. left join customers c on c.id = v."customerId"
  881. where v.id = $1`,
  882. [vehicleId]
  883. )
  884. const row = result.rows[0]
  885. if (!row) throw new Error(`no vehicle ${vehicleId}`)
  886. return row
  887. })
  888. }
  889. /** Removes a workshop setting so the app falls back to its default for it. */
  890. export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
  891. await withDb((db) =>
  892. db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
  893. organizationId,
  894. key,
  895. ])
  896. )
  897. }
  898. /** Marks the address verified, as clicking the mail's link would. */
  899. export async function markEmailVerified(email: string): Promise<void> {
  900. await withDb((db) =>
  901. db.query(`update users set "emailVerified" = true where lower(email) = lower($1)`, [email])
  902. )
  903. }
  904. export interface MembershipRecord {
  905. id: string
  906. role: string
  907. roleId: string | null
  908. }
  909. /** A person's membership of a workshop, as stored. */
  910. export async function membershipOf(
  911. email: string,
  912. organizationId: string
  913. ): Promise<MembershipRecord> {
  914. return withDb(async (db) => {
  915. const result = await db.query<MembershipRecord>(
  916. `select m.id, m.role, m."roleId" from organization_members m
  917. join users u on u.id = m."userId"
  918. where lower(u.email) = lower($1) and m."organizationId" = $2`,
  919. [email, organizationId]
  920. )
  921. if (!result.rows[0]) throw new Error(`${email} is not a member of ${organizationId}`)
  922. return result.rows[0]
  923. })
  924. }
  925. /** A role that carries the admin switch and nothing else. */
  926. export async function createAdminRole(organizationId: string, name: string): Promise<string> {
  927. return withDb(async (db) => {
  928. const result = await db.query<{ id: string }>(
  929. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  930. values (gen_random_uuid()::text, $1, true, $2, now(), now()) returning id`,
  931. [name, organizationId]
  932. )
  933. return result.rows[0].id
  934. })
  935. }
  936. export async function deleteRoles(ids: string[]): Promise<void> {
  937. if (ids.length === 0) return
  938. await withDb((db) => db.query(`delete from roles where id = any($1::text[])`, [ids]))
  939. }
  940. /** A technician on a workshop's board, made here so the spec owns it. */
  941. export async function insertTechnician(organizationId: string, name: string): Promise<string> {
  942. return withDb(async (db) => {
  943. const result = await db.query<{ id: string }>(
  944. `insert into technicians (id, name, "organizationId", "createdAt", "updatedAt")
  945. values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
  946. [name, organizationId]
  947. )
  948. return result.rows[0].id
  949. })
  950. }
  951. export async function insertWorkBay(organizationId: string, name: string): Promise<string> {
  952. return withDb(async (db) => {
  953. const result = await db.query<{ id: string }>(
  954. `insert into work_bays (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 deleteTechnicians(ids: string[]): Promise<void> {
  962. if (ids.length === 0) return
  963. await withDb((db) => db.query(`delete from technicians where id = any($1::text[])`, [ids]))
  964. }
  965. export async function deleteWorkBays(ids: string[]): Promise<void> {
  966. if (ids.length === 0) return
  967. await withDb((db) => db.query(`delete from work_bays where id = any($1::text[])`, [ids]))
  968. }
  969. export interface JobAssignment {
  970. id: string
  971. technicianId: string | null
  972. workBayId: string | null
  973. }
  974. /** A job's technician and bay as stored, by its id. */
  975. export async function jobAssignment(serviceRecordId: string): Promise<JobAssignment> {
  976. return withDb(async (db) => {
  977. const result = await db.query<JobAssignment>(
  978. `select id, "technicianId", "workBayId" from service_records where id = $1`,
  979. [serviceRecordId]
  980. )
  981. if (!result.rows[0]) throw new Error(`no job ${serviceRecordId}`)
  982. return result.rows[0]
  983. })
  984. }
  985. /** How many jobs a vehicle has, before and after an attempt to add one. */
  986. export async function jobCount(vehicleId: string): Promise<number> {
  987. return withDb(async (db) => {
  988. const result = await db.query<{ n: string }>(
  989. `select count(*)::text as n from service_records where "vehicleId" = $1`,
  990. [vehicleId]
  991. )
  992. return Number(result.rows[0].n)
  993. })
  994. }
  995. /** Inbound WhatsApp messages with exactly this body, for a workshop. */
  996. export async function inboundWhatsappCount(organizationId: string, body: string): Promise<number> {
  997. return withDb(async (db) => {
  998. const result = await db.query<{ n: string }>(
  999. `select count(*)::text as n from whatsapp_messages
  1000. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  1001. [organizationId, body]
  1002. )
  1003. return Number(result.rows[0].n)
  1004. })
  1005. }
  1006. export async function deleteInboundWhatsapp(organizationId: string, body: string): Promise<void> {
  1007. await withDb((db) =>
  1008. db.query(
  1009. `delete from whatsapp_messages where "organizationId" = $1 and direction = 'inbound' and body like $2`,
  1010. [organizationId, `${body}%`]
  1011. )
  1012. )
  1013. }
  1014. /** Open sessions a person has, however many browsers and phones that is. */
  1015. export async function sessionCountFor(email: string): Promise<number> {
  1016. return withDb(async (db) => {
  1017. const result = await db.query<{ n: string }>(
  1018. `select count(*)::text as n from sessions s join users u on u.id = s."userId"
  1019. where lower(u.email) = lower($1) and s."expiresAt" > now()`,
  1020. [email]
  1021. )
  1022. return Number(result.rows[0].n)
  1023. })
  1024. }
  1025. /** Device rows a person has whose user agent mentions `needle`. */
  1026. export async function deviceCountFor(email: string, needle: string): Promise<number> {
  1027. return withDb(async (db) => {
  1028. const result = await db.query<{ n: string }>(
  1029. `select count(*)::text as n from user_devices d join users u on u.id = d."userId"
  1030. where lower(u.email) = lower($1) and d."userAgent" like $2`,
  1031. [email, `%${needle}%`]
  1032. )
  1033. return Number(result.rows[0].n)
  1034. })
  1035. }
  1036. /**
  1037. * Matches better-auth's scrypt parameters, the same way the seed does, so a
  1038. * password written here is accepted by the sign-in form.
  1039. */
  1040. function hashPassword(password: string): string {
  1041. const N = 16384
  1042. const r = 16
  1043. const p = 1
  1044. const salt = randomBytes(16).toString('hex')
  1045. const key = scryptSync(password.normalize('NFKC'), salt, 64, { N, r, p, maxmem: 128 * N * r * 2 })
  1046. return `${salt}:${key.toString('hex')}`
  1047. }
  1048. export interface PlantedWorkshop {
  1049. userId: string
  1050. organizationId: string
  1051. }
  1052. /**
  1053. * A second tenant, put straight into the database.
  1054. *
  1055. * A self-hosted install opens one workshop; every later sign-up is told to
  1056. * ask for an invitation. A spec that needs a second, separate workshop to
  1057. * prove isolation therefore cannot sign one up and has to plant it: a
  1058. * verified person with a password, and a workshop they own.
  1059. */
  1060. export async function plantWorkshop(input: {
  1061. name: string
  1062. email: string
  1063. password: string
  1064. workshopName: string
  1065. }): Promise<PlantedWorkshop> {
  1066. return withDb(async (db) => {
  1067. const user = await db.query<{ id: string }>(
  1068. `insert into users (id, name, email, "emailVerified", "termsAcceptedAt", "createdAt", "updatedAt")
  1069. values (md5(random()::text || clock_timestamp()::text), $1, $2, true, now(), now(), now())
  1070. returning id`,
  1071. [input.name, input.email.toLowerCase()]
  1072. )
  1073. const userId = user.rows[0].id
  1074. await db.query(
  1075. `insert into accounts (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt")
  1076. values (md5(random()::text || clock_timestamp()::text), $1, 'credential', $1, $2, now(), now())`,
  1077. [userId, hashPassword(input.password)]
  1078. )
  1079. const org = await db.query<{ id: string }>(
  1080. `insert into organizations (id, name, "createdAt", "updatedAt")
  1081. values (md5(random()::text || clock_timestamp()::text), $1, now(), now())
  1082. returning id`,
  1083. [input.workshopName]
  1084. )
  1085. const organizationId = org.rows[0].id
  1086. await db.query(
  1087. `insert into organization_members (id, role, "userId", "organizationId")
  1088. values (md5(random()::text || clock_timestamp()::text), 'owner', $1, $2)`,
  1089. [userId, organizationId]
  1090. )
  1091. return { userId, organizationId }
  1092. })
  1093. }
  1094. /** Removes a person and, through the cascade, their memberships and sessions. */
  1095. export async function deletePersonWithEmail(email: string): Promise<void> {
  1096. await withDb((db) => db.query(`delete from users where lower(email) = lower($1)`, [email]))
  1097. }
  1098. /** How many workshops the install has. */
  1099. export async function organizationCount(): Promise<number> {
  1100. return withDb(async (db) => {
  1101. const result = await db.query<{ count: string }>(
  1102. `select count(*)::text as count from organizations`
  1103. )
  1104. return Number(result.rows[0].count)
  1105. })
  1106. }
  1107. /**
  1108. * One customer, one vehicle and one work order in a workshop, for a spec
  1109. * that needs a job to point at. A planted workshop has none of the sample
  1110. * data onboarding would have given it.
  1111. */
  1112. export async function plantJob(
  1113. organizationId: string,
  1114. userId: string,
  1115. title: string
  1116. ): Promise<{ serviceRecordId: string; vehicleId: string }> {
  1117. return withDb(async (db) => {
  1118. const customer = await db.query<{ id: string }>(
  1119. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  1120. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1121. returning id`,
  1122. [`${title} customer`, userId, organizationId]
  1123. )
  1124. const vehicle = await db.query<{ id: string }>(
  1125. `insert into vehicles (id, make, model, year, "userId", "organizationId", "customerId", "updatedAt")
  1126. values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2020, $2, $3, $4, now())
  1127. returning id`,
  1128. [title, userId, organizationId, customer.rows[0].id]
  1129. )
  1130. const vehicleId = vehicle.rows[0].id
  1131. const job = await db.query<{ id: string }>(
  1132. `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
  1133. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1134. returning id`,
  1135. [title, vehicleId, organizationId]
  1136. )
  1137. return { serviceRecordId: job.rows[0].id, vehicleId }
  1138. })
  1139. }
  1140. // ─── Notifications ───────────────────────────────────────────────────────────
  1141. export interface PlantedNotification {
  1142. type: string
  1143. title: string
  1144. message: string
  1145. entityType: string
  1146. entityId: string
  1147. entityUrl: string
  1148. }
  1149. /**
  1150. * A notification written straight into the bell, with the address the code
  1151. * that raises it builds. Planting it rather than provoking it lets a spec
  1152. * follow links whose trigger needs a provider the harness cannot play (an
  1153. * inbound SMS, a Telegram webhook), and also links already stored in the old
  1154. * shape, which the pages still have to honour.
  1155. */
  1156. export async function plantNotification(
  1157. organizationId: string,
  1158. n: PlantedNotification
  1159. ): Promise<string> {
  1160. return withDb(async (db) => {
  1161. const result = await db.query<{ id: string }>(
  1162. `insert into notifications (id, type, title, message, "entityType", "entityId", "entityUrl", read, "organizationId", "createdAt")
  1163. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, false, $7, now())
  1164. returning id`,
  1165. [n.type, n.title, n.message, n.entityType, n.entityId, n.entityUrl, organizationId]
  1166. )
  1167. return result.rows[0].id
  1168. })
  1169. }
  1170. export async function deleteNotifications(ids: string[]): Promise<void> {
  1171. if (ids.length === 0) return
  1172. await withDb((db) => db.query('delete from notifications where id = any($1)', [ids]))
  1173. }
  1174. /** An inbound message on a customer's thread, as the webhook would have stored it. */
  1175. export async function plantInboundMessage(
  1176. channel: 'sms' | 'telegram',
  1177. organizationId: string,
  1178. customerId: string,
  1179. body: string
  1180. ): Promise<void> {
  1181. await withDb((db) =>
  1182. channel === 'sms'
  1183. ? db.query(
  1184. `insert into sms_messages (id, direction, "fromNumber", "toNumber", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1185. values (md5(random()::text || clock_timestamp()::text), 'inbound', '+4790000000', '+4790000001', $1, 'received', $2, $3, now(), now())`,
  1186. [body, organizationId, customerId]
  1187. )
  1188. : db.query(
  1189. `insert into telegram_messages (id, direction, "chatId", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1190. values (md5(random()::text || clock_timestamp()::text), 'inbound', '777000', $1, 'received', $2, $3, now(), now())`,
  1191. [body, organizationId, customerId]
  1192. )
  1193. )
  1194. }
  1195. /**
  1196. * Links a customer to a Telegram chat and hands back what was there before.
  1197. * A real inbound Telegram message only ever comes from a linked chat, and the
  1198. * conversation shows nothing but "not connected yet" without one.
  1199. */
  1200. export async function linkTelegramChat(
  1201. customerId: string,
  1202. chatId: string | null
  1203. ): Promise<string | null> {
  1204. return withDb(async (db) => {
  1205. const before = await db.query<{ telegramChatId: string | null }>(
  1206. 'select "telegramChatId" from customers where id = $1',
  1207. [customerId]
  1208. )
  1209. await db.query('update customers set "telegramChatId" = $1 where id = $2', [chatId, customerId])
  1210. return before.rows[0]?.telegramChatId ?? null
  1211. })
  1212. }
  1213. export async function deleteMessagesWithBody(body: string): Promise<void> {
  1214. await withDb(async (db) => {
  1215. await db.query('delete from sms_messages where body = $1', [body])
  1216. await db.query('delete from telegram_messages where body = $1', [body])
  1217. })
  1218. }
  1219. /** A vehicle job with the customer it belongs to, taken from one row so the ids agree. */
  1220. export async function jobWithCustomer(organizationId: string): Promise<{
  1221. vehicleId: string
  1222. serviceRecordId: string
  1223. customerId: string
  1224. customerName: string
  1225. }> {
  1226. return withDb(async (db) => {
  1227. const result = await db.query<{
  1228. vehicleId: string
  1229. serviceRecordId: string
  1230. customerId: string
  1231. customerName: string
  1232. }>(
  1233. `select v.id as "vehicleId", s.id as "serviceRecordId", c.id as "customerId", c.name as "customerName"
  1234. from service_records s
  1235. join vehicles v on v.id = s."vehicleId"
  1236. join customers c on c.id = v."customerId"
  1237. where s."organizationId" = $1
  1238. order by s."createdAt" asc
  1239. limit 1`,
  1240. [organizationId]
  1241. )
  1242. const row = result.rows[0]
  1243. if (!row) throw new Error('the seeded workshop has no vehicle job with a customer')
  1244. return row
  1245. })
  1246. }