diff --git a/package-lock.json b/package-lock.json index 509a2e1f..6b205c00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9627,6 +9627,7 @@ "@types/react-router-dom": "^5.1.8", "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "prop-types": "^15.7.2", @@ -9659,6 +9660,29 @@ "resolved": "web/shared", "link": true }, + "web/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "web/shared": {} }, "dependencies": { @@ -11069,6 +11093,7 @@ "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "eslint": "^7.32.0", "eslint-config-airbnb-typescript": "^12.3.1", "eslint-plugin-import": "^2.24.0", @@ -11091,6 +11116,15 @@ "dependencies": { "@porkbellypro/crm-shared": { "version": "file:web/shared" + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } } } }, diff --git a/server/src/server.ts b/server/src/server.ts index aded9818..6803027f 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,9 +1,11 @@ import { ArgumentParser } from 'argparse'; -import express from 'express'; +import express, { ErrorRequestHandler, RequestHandler } from 'express'; +import { readFile } from 'fs/promises'; import { createConnection } from 'mongoose'; import { resolve } from 'path'; import { env } from 'process'; import { ApiRouter } from './api/api-router'; +import { HttpStatusError } from './api/HttpStatusError'; interface IArgs { dist: string; @@ -12,6 +14,41 @@ interface IArgs { secret: string; } +type ServeIndexRequestHandlerAsync = + (distPath: string, ...args: Parameters) => Promise; + +const serveIndexAsync: ServeIndexRequestHandlerAsync = async (distPath, _req, res) => { + const index = resolve(distPath, 'index.html'); + let file: Buffer; + try { + file = await readFile(index); + } catch { + throw new HttpStatusError(404); + } + res.status(200).contentType('.html').send(file); +}; + +function serveIndex(distPath: string): RequestHandler { + /* eslint-disable-next-line @typescript-eslint/no-shadow */ + const serveIndex: RequestHandler = (...args) => { + serveIndexAsync(distPath, ...args).catch(args[2]); + }; + + return serveIndex; +} + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + if (err) { + if (err instanceof HttpStatusError) { + res.status(err.code).send(err.message); + return; + } + } + + res.sendStatus(500); +}; + async function main() { const parser = new ArgumentParser(); parser.add_argument('-d', '--dist'); @@ -36,6 +73,8 @@ async function main() { app.use('/api', apiRouter.router); app.use(dist); + app.get('*', serveIndex(distPath)); + app.use(errorHandler); console.log(`Listening on port ${port}...`); app.listen(port); diff --git a/shared/src/__tests__/ensure.ts b/shared/src/__tests__/ensure.ts new file mode 100644 index 00000000..fbf80e86 --- /dev/null +++ b/shared/src/__tests__/ensure.ts @@ -0,0 +1,85 @@ +import { ensureArray, ensureObject, ensureType } from '../ensure'; + +const map = { + undefined, + number: Number(), + bigint: BigInt(Number()), + boolean: Boolean(), + string: String(), + symbol: Symbol('symbol'), + object: Object(), + function() { }, +}; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +function asTypeTag(tag: string): any { + return tag; +} + +describe('ensure tests', () => { + describe('ensureType tests', () => { + describe('Success tests', () => { + Object + .entries(map) + .forEach(([k, v]) => test(k, () => { + expect(() => ensureType(v, asTypeTag(k))).not.toThrow(); + })); + + test('null', () => { + expect(() => ensureType(null, 'object')).not.toThrow(); + }); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .forEach(([k0, v]) => describe(k0, () => { + Object + .keys(map) + .filter((k1) => k0 !== k1) + .map((k1) => test(k1, () => { + expect(() => ensureType(v, asTypeTag(k1))).toThrow(); + })); + })); + + describe('null', () => { + Object + .keys(map) + .filter((k) => k !== 'object') + .map((k) => test(k, () => { + expect(() => ensureType(null, asTypeTag(k))).toThrow(); + })); + }); + }); + }); + + describe('ensureObject tests', () => { + test('Success test', () => { + expect(() => ensureObject(Object())).not.toThrow(); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .filter(([k]) => k !== 'object') + .map(([k, v]) => test(k, () => { + expect(() => ensureObject(v)).toThrow(); + })); + }); + }); + + describe('ensureArray tests', () => { + test('Success test', () => { + expect(() => ensureArray([])).not.toThrow(); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .concat([['null', null]]) + .map(([k, v]) => test(k, () => { + expect(() => ensureArray(v)).toThrow(); + })); + }); + }); +}); diff --git a/shared/src/__tests__/index.ts b/shared/src/__tests__/index.ts deleted file mode 100644 index 0bb5e88a..00000000 --- a/shared/src/__tests__/index.ts +++ /dev/null @@ -1 +0,0 @@ -test('Dummy test', () => {}); diff --git a/shared/src/ensure.ts b/shared/src/ensure.ts new file mode 100644 index 00000000..0fcea5d5 --- /dev/null +++ b/shared/src/ensure.ts @@ -0,0 +1,33 @@ +import type { TypeOfTag } from 'typescript'; + +export type AnyUnknown = { [k: string]: unknown }; + +export function ensureType(value: unknown, type: 'undefined'): undefined; +export function ensureType(value: unknown, type: 'number'): number; +export function ensureType(value: unknown, type: 'bigint'): bigint; +export function ensureType(value: unknown, type: 'boolean'): boolean; +export function ensureType(value: unknown, type: 'string'): string; +export function ensureType(value: unknown, type: 'symbol'): symbol; +export function ensureType(value: unknown, type: 'object'): AnyUnknown | null; +export function ensureType(value: unknown, type: 'function'): CallableFunction; + +export function ensureType(value: unknown, type: TypeOfTag): unknown { + if (typeof value !== type) throw new Error(`typeof value is not ${type}`); + + return value; +} + +export function ensureObject(value: unknown): AnyUnknown { + if (value == null) throw new Error('value is nullish'); + + const ensured = ensureType(value, 'object'); + if (ensured == null) throw new Error('ensured is null'); + + return ensured; +} + +export function ensureArray(value: unknown): unknown[] { + if (!(value instanceof Array)) throw new Error(`value is not instanceof ${Array.name}`); + + return value; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 5a4793a9..2e987267 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1 +1,2 @@ export * from './api-types'; +export * from './ensure'; diff --git a/web/build.mjs b/web/build.mjs index 95ab9b6b..d503a595 100755 --- a/web/build.mjs +++ b/web/build.mjs @@ -49,6 +49,7 @@ webpack({ }, plugins: [ new HtmlWebpackPlugin({ + publicPath: '/', title: args.title, template: src('index.ejs') }) diff --git a/web/package.json b/web/package.json index b11de142..93188f63 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@types/react-router-dom": "^5.1.8", "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "prop-types": "^15.7.2", diff --git a/web/src/App.tsx b/web/src/App.tsx index b382b6d4..bd94c009 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,81 +1,430 @@ import { mergeStyleSets } from '@fluentui/react'; -import { History } from 'history'; +import { ensureArray, ensureObject, ensureType } from '@porkbellypro/crm-shared'; +import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Dispatch, SetStateAction, useState } from 'react'; import { - BrowserRouter, Route, Router, RouterProps, Switch, + BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; import { - AppProvider, IAppContext, + AppProvider, IAppContext, ISettings, IUser, } from './AppContext'; import { Header } from './components/Header'; +import { + CardFieldMethodsFactory, + CardMethods, + ICard, + ICardData, + ICardProperties, + cardDataDefaults, + fromRaw, + implement, +} from './controllers/Card'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; -const getClassNames = () => mergeStyleSets({ - contentRoot: { - height: '100%', - }, -}); +const getClassNames = () => { + const headerHeight = '60px'; + + return mergeStyleSets({ + header: { + height: headerHeight, + left: '0', + position: 'fixed', + right: '0', + }, + body: { + bottom: '0', + position: 'fixed', + left: '0', + right: '0', + top: headerHeight, + }, + }); +}; export interface IAppProps { - history?: RouterProps['history']; + useMemoryRouter?: boolean; +} + +function notAcceptable(): ResponseStatus { + return new ResponseStatus({ + ok: false, + status: 406, + statusText: 'Not Acceptable', + }); +} + +interface ICardOverrideData { + readonly base?: ICardData; + readonly overrides: Partial; +} + +interface IUserStatic { + readonly username: string; + readonly settings: ISettings; + readonly cards: readonly ICardData[]; +} + +type GetMeResult = { + status: ResponseStatus; + user?: IUserStatic; +}; + +async function getMe(): Promise { + const res = await fetch('/api/me', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!res.ok) return { status: new ResponseStatus(res) }; + + try { + if (res.headers.get('Content-Type')?.startsWith('application/json;') !== true) { + throw new Error(); + } + + const body = ensureObject(await res.json()); + const username = ensureType(body.username, 'string'); + const settings = ensureObject(body.settings); + const cards = ensureArray(body.cards).map(fromRaw); + + return { + status: new ResponseStatus(res), + user: { + username, + settings, + cards, + }, + }; + } catch { + return { status: notAcceptable() }; + } +} + +function notImplemented() { + return new Error('Not implemented'); +} + +function implementDelete( + card: ICardData, + userState: IUserStatic, + setUser: Dispatch>, +): ICard['delete'] { + return (async () => { + if (card.id == null) throw new Error('card.id is nullish'); + + const res = await fetch('/api/card', { + method: 'DELETE', + body: JSON.stringify({ + id: card.id, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + setUser({ + ...userState, + cards: userState.cards.filter((that) => that !== card), + }); + } + return new ResponseStatus(res); + }); +} + +function implementCard( + card: ICardData, + userState: IUserStatic, + setUser: Dispatch>, +): ICard { + const cardMethods: CardMethods = { + update() { throw notImplemented(); }, + commit() { return Promise.reject(notImplemented()); }, + delete: implementDelete(card, userState, setUser), + }; + const fieldMethodsFactory: CardFieldMethodsFactory = () => ({ + update() { throw notImplemented(); }, + remove() { throw notImplemented(); }, + }); + return implement(card, cardMethods, fieldMethodsFactory); +} + +function implementCardOverride( + data: ICardOverrideData, + setDetail: Dispatch>, + userState: IUserStatic, + setUser: Dispatch>, +): ICard { + const { base, overrides: { image, ...overrides } } = data; + const cardData: ICardData = { + ...cardDataDefaults, + ...base, + ...overrides, + }; + if (image !== undefined) { + cardData.image = image === null ? undefined : image; + } + const cardMethods: CardMethods = { + update(updates) { + const newOverrides: Partial = { + ...data.overrides, + ...updates, + }; + setDetail({ + ...data, + overrides: newOverrides, + }); + }, + async commit() { + const put = base?.id == null; + + if (!put && !Object.entries(data.overrides).some(([, v]) => v !== undefined)) { + return new ResponseStatus({ + ok: true, + status: 200, + statusText: 'OK', + }); + } + + let bodyObj; + if (put) { + bodyObj = Object.fromEntries( + Object + .entries(cardDataDefaults) + .concat(Object.entries(overrides), [['tags', []]]), + ); + } else { + if (base?.id == null) throw new Error('Unreachable'); + bodyObj = Object.fromEntries(Object + .entries(overrides) + .concat([['id', base.id]])); + } + if (image !== undefined) { + bodyObj.image = image; + } + const body = JSON.stringify(bodyObj); + const res = await fetch('/api/card', { + method: put ? 'PUT' : 'PATCH', + body, + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + const raw = await res.json(); + const updated = fromRaw(raw); + if (put) { + setUser({ + ...userState, + cards: userState.cards.concat(updated), + }); + } else { + setUser({ + ...userState, + cards: userState.cards.map((that) => { + if (that === base) return updated; + return that; + }), + }); + } + } + return new ResponseStatus(res); + }, + delete: base == null + ? () => Promise.reject(notImplemented()) + : implementDelete(base, userState, setUser), + }; + const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ + update({ key, value }) { + cardMethods.update({ + fields: cardData.fields.map( + (existing) => (field === existing + ? { key: key ?? existing.key, value: value ?? existing.value } + : existing), + ), + }); + }, + remove() { + cardMethods.update({ + fields: cardData.fields.filter((existing) => field !== existing), + }); + }, + }); + return implement(cardData, cardMethods, fieldMethodsFactory); } -export const App: React.VoidFunctionComponent = ({ history }) => { +const AppComponent: React.VoidFunctionComponent = () => { + const [userState, setUserState] = useState(); + const [detail, setDetail] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const history = useHistory(); + + const setUser: Dispatch> = (value) => { + let newState: IUserStatic | null | undefined; + if (typeof value === 'function') newState = value(userState); + else newState = value; + setUserState(newState); + let newBase: ICardData | undefined; + if (detail?.base?.id != null && newState != null) { + newBase = newState.cards.find((card) => card.id === detail?.base?.id); + } + setDetail(newBase == null + ? null + : { + base: newBase, + overrides: {}, + }); + }; + + const updateMe: () => Promise = async () => { + const { status, user } = await getMe(); + const { ok } = status; + + if (ok) { + if (user == null) throw new Error('Expected result.user to be non-null'); + + setUser(user); + } + + return status; + }; + + if (userState === undefined) { + updateMe(); + } + + const user: IUser | null = userState == null + ? null + : { + username: userState.username, + settings: userState.settings, + cards: userState.cards.map((card) => implementCard(card, userState, setUser)), + }; + const context: IAppContext = { - searchQuery: '', - user: null, - update() { }, - showCardDetail() { }, - login() { - return Promise.resolve(new ResponseStatus({ - ok: true, - status: 200, - statusText: 'OK', - })); + searchQuery, + user, + update({ searchQuery: query }) { + if (query != null) setSearchQuery(query); + }, + showCardDetail(card) { + if (userState == null) throw new Error('userState is nullish'); + if (card == null) { + setDetail(null); + } else { + const base = userState.cards.find((existing) => existing.id === card.id); + if (base == null) throw new Error('Not found'); + setDetail({ + base, + overrides: {}, + }); + } + }, + newCard() { + setDetail({ + overrides: {}, + }); + }, + login(username, password, register) { + return (async function loginAsync() { + const body = { + username, + password: Buffer.from( + await crypto.subtle.digest( + 'SHA-256', + Buffer.from(password), + ), + ).toString('hex'), + }; + const endpoint = register ? '/api/register' : '/api/login'; + + const res = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + if (register) history.push('/login'); + else { + return updateMe(); + } + } + + return new ResponseStatus(res); + }()); }, logout() { - return Promise.resolve(new ResponseStatus({ - ok: true, - status: 200, - statusText: 'OK', - })); + return (async function logoutAsync() { + const res = await fetch('/api/logout', { method: 'POST' }); + if (res.ok) setUser(null); + + return new ResponseStatus(res); + }()); }, }; - const { contentRoot } = getClassNames(); - - const content = ( - - - - - - - - - - - - ); + let override: ICard | undefined; + if (userState != null) { + override = detail != null + ? implementCardOverride( + detail, + setDetail, + userState, + setUser, + ) + : undefined; + } + + const { header, body } = getClassNames(); + + const loggedIn = userState != null; + return ( -
-
- {history != null - ? {content} - : {content}} +
+
+
+
+ + + {loggedIn ? : } + + + {loggedIn ? : } + + + {loggedIn ? : } + + + + +
); }; +export const App: React.VoidFunctionComponent = ({ useMemoryRouter }) => { + type RouterType = typeof MemoryRouter & typeof BrowserRouter; + const Router: RouterType = useMemoryRouter ? MemoryRouter : BrowserRouter; + + return ( + + + + ); +}; + App.propTypes = { - history: PropTypes.object as React.Validator | undefined, + useMemoryRouter: PropTypes.bool, }; App.defaultProps = { - history: undefined, + useMemoryRouter: false, }; diff --git a/web/src/AppContext.ts b/web/src/AppContext.ts index 0034b469..27e3af9d 100644 --- a/web/src/AppContext.ts +++ b/web/src/AppContext.ts @@ -19,6 +19,7 @@ export interface IAppContext extends Readonly { readonly user: IUser | null; update(props: Partial): void; showCardDetail(card: ICard | null): void; + newCard(): void; login(username: string, password: string, register?: boolean): Promise; logout(): Promise; } diff --git a/web/src/__tests__/App.tsx b/web/src/__tests__/App.tsx deleted file mode 100644 index 4d5944c4..00000000 --- a/web/src/__tests__/App.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { create } from 'react-test-renderer'; -import { App } from '../App'; -import './disable-icon-warnings.helpers'; - -describe('App tests', () => { - test('Simple render', () => { - const history = createMemoryHistory(); - - const tree = create().toJSON(); - expect(tree).toMatchInlineSnapshot(` -
-`); - }); -}); diff --git a/web/src/__tests__/controllers/Card.ts b/web/src/__tests__/controllers/Card.ts new file mode 100644 index 00000000..b15eeed6 --- /dev/null +++ b/web/src/__tests__/controllers/Card.ts @@ -0,0 +1,100 @@ +import { Card } from '@porkbellypro/crm-shared'; +import { fromRaw } from '../../controllers/Card'; + +type DeepReadonly = T extends { [k in keyof T]: T[k] } + ? { readonly [k in keyof T]: DeepReadonly; } + : Readonly; + +describe('Card tests', () => { + const template: DeepReadonly = { + id: '000000000000000000000000', + favorite: false, + name: 'name', + phone: 'phone', + email: 'email', + jobTitle: 'jobTitle', + company: 'company', + hasImage: false, + fields: [ + { + key: 'Key 1', + value: 'Value 1', + }, + { + key: 'Key 2', + value: 'Value 2', + }, + ], + tags: [], + }; + + describe('fromRaw tests', () => { + test('Success test: without image', () => { + const obj = fromRaw(template); + expect(obj).toMatchInlineSnapshot(` +Object { + "company": "company", + "email": "email", + "favorite": false, + "fields": Array [ + Object { + "key": "Key 1", + "value": "Value 1", + }, + Object { + "key": "Key 2", + "value": "Value 2", + }, + ], + "id": "000000000000000000000000", + "jobTitle": "jobTitle", + "name": "name", + "phone": "phone", +} +`); + }); + + test('Success test: with image', () => { + const obj = fromRaw({ + ...template, + hasImage: true, + }); + expect(obj).toMatchInlineSnapshot(` +Object { + "company": "company", + "email": "email", + "favorite": false, + "fields": Array [ + Object { + "key": "Key 1", + "value": "Value 1", + }, + Object { + "key": "Key 2", + "value": "Value 2", + }, + ], + "id": "000000000000000000000000", + "image": "/image/000000000000000000000000", + "jobTitle": "jobTitle", + "name": "name", + "phone": "phone", +} +`); + }); + + describe('Fail tests', () => { + Object + .keys(template) + // TODO: tags not implemented yet + .filter((k) => k !== 'tags') + .map((k0) => test(k0, () => { + const fn = () => fromRaw(Object.fromEntries(Object + .entries(template) + .filter(([k1]) => k1 !== k0))); + + expect(fn).toThrow(); + })); + }); + }); +}); diff --git a/web/src/__tests__/controllers/CardField.ts b/web/src/__tests__/controllers/CardField.ts new file mode 100644 index 00000000..b1edb8fd --- /dev/null +++ b/web/src/__tests__/controllers/CardField.ts @@ -0,0 +1,37 @@ +import { fromRaw } from '../../controllers/CardField'; + +describe('CardField tests', () => { + describe('fromRaw tests', () => { + test('Success test', () => { + const json = JSON.parse('{"key":"k","value":"v"}'); + const obj = fromRaw(json); + expect(obj.key).toBe('k'); + expect(obj.value).toBe('v'); + }); + + test('Fail test: unexpected key type', () => { + const json = JSON.parse('{"key":0,"value":"v"}'); + expect(() => fromRaw(json)).toThrow(); + }); + + test('Fail test: missing key', () => { + const json = JSON.parse('{"value":"v"}'); + expect(() => fromRaw(json)).toThrow(); + }); + + test('Fail test: unexpected value type', () => { + const json = JSON.parse('{"key":"k","value":0}'); + expect(() => fromRaw(json)).toThrow(); + }); + + test('Fail test: missing value', () => { + const json = JSON.parse('{"key":"k"}'); + expect(() => fromRaw(json)).toThrow(); + }); + + test('Fail test: unexpected raw value', () => { + const json = JSON.parse('[]'); + expect(() => fromRaw(json)).toThrow(); + }); + }); +}); diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index 8ae491ae..fe708696 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -1,6 +1,15 @@ -import { ObjectId } from '@porkbellypro/crm-shared'; +import { + ObjectId, ensureArray, ensureObject, ensureType, +} from '@porkbellypro/crm-shared'; import { ResponseStatus } from '../ResponseStatus'; -import { ICardField, ICardFieldProperties } from './CardField'; +import { + CardFieldMethods, + ICardField, + ICardFieldData, + ICardFieldProperties, + fromRaw as fieldFromRaw, + implement as implementField, +} from './CardField'; interface ICardPropertiesCommon { favorite: boolean; @@ -12,7 +21,7 @@ interface ICardPropertiesCommon { } export interface ICardProperties extends ICardPropertiesCommon { - image: [Blob, string] | null; + image: string | null; fields: readonly ICardFieldProperties[]; } @@ -25,6 +34,66 @@ export interface ICard extends Readonly { delete(): Promise; } -export function newCard(): ICard { - throw new Error('Not implemented'); +export interface ICardData extends ICardPropertiesCommon { + id?: ObjectId; + image?: string; + fields: readonly Readonly[]; +} + +export const cardDataDefaults: ICardData = Object.freeze({ + favorite: false, + name: '', + phone: '', + email: '', + jobTitle: '', + company: '', + fields: [], +}); + +export function fromRaw(raw: unknown): ICardData { + const { + id, + favorite, + name, + phone, + email, + jobTitle, + company, + hasImage, + fields: fieldsRaw, + } = ensureObject(raw); + + const result = { + id: ensureType(id, 'string'), + favorite: ensureType(favorite, 'boolean'), + name: ensureType(name, 'string'), + phone: ensureType(phone, 'string'), + email: ensureType(email, 'string'), + jobTitle: ensureType(jobTitle, 'string'), + company: ensureType(company, 'string'), + image: ensureType(hasImage, 'boolean') ? `/image/${id}` : undefined, + fields: ensureArray(fieldsRaw).map(fieldFromRaw), + }; + + if (result.image === undefined) delete result.image; + + return result; +} + +export type CardMethods = Omit; + +export type CardFieldMethodsFactory = ( + field: Readonly, +) => CardFieldMethods; + +export function implement( + data: Readonly, + methods: CardMethods, + fieldMethodsFactory: CardFieldMethodsFactory, +): ICard { + return { + ...data, + fields: data.fields.map((field) => implementField(field, fieldMethodsFactory(field))), + ...methods, + }; } diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index 2a31d3e8..3545e07b 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -1,3 +1,5 @@ +import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; + export interface ICardFieldProperties { key: string; value: string; @@ -7,3 +9,26 @@ export interface ICardField extends Readonly { update(props: Partial): void; remove(): void; } + +export type ICardFieldData = ICardFieldProperties; + +export function fromRaw(raw: unknown): ICardFieldData { + const { key, value } = ensureObject(raw); + + return { + key: ensureType(key, 'string'), + value: ensureType(value, 'string'), + }; +} + +export type CardFieldMethods = Omit; + +export function implement( + data: Readonly, + methods: CardFieldMethods, +): ICardField { + return { + ...data, + ...methods, + }; +}