refactor api and sdk

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-12 12:16:33 +02:00
parent 5b9a01c665
commit 8a2417de5a
18 changed files with 298 additions and 292 deletions

View File

@@ -17,6 +17,7 @@
"express": "^4.18.2",
"morgan": "^1.10.0",
"prisma": "^5.4.2",
"random-animal-name": "^0.1.1",
"uuid": "^9.0.1"
},
"devDependencies": {

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "profiles_project_id_external_id_key";
-- AlterTable
ALTER TABLE "profiles" ALTER COLUMN "external_id" DROP NOT NULL;

View File

@@ -69,7 +69,7 @@ model Event {
model Profile {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
external_id String
external_id String?
first_name String?
last_name String?
email String?
@@ -82,7 +82,6 @@ model Profile {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([project_id, external_id])
@@map("profiles")
}

View File

@@ -1,9 +1,10 @@
import express from 'express'
import express, { ErrorRequestHandler } from 'express'
import events from './routes/events'
import profiles from './routes/profiles'
import { authMiddleware } from './middlewares/auth'
import morgan from 'morgan'
import { setup } from './routes/setup'
import { errorHandler } from './middlewares/errors'
const app = express()
const port = process.env.PORT || 8080
@@ -21,6 +22,9 @@ if (process.env.SETUP) {
app.use(authMiddleware)
app.use('/api/sdk', events)
app.use('/api/sdk', profiles)
app.use(errorHandler)
app.listen(port, () => {
console.log(`Listening on port ${port}...`)
})

View File

@@ -1,14 +1,12 @@
import { NextFunction, Request, Response } from "express"
import { db } from "../db"
import { createError } from "../responses/errors"
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const secret = req.headers['mixan-client-secret'] as string | undefined
if(!secret) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Missing client secret',
})
return next(createError(401, 'Misisng client secret'))
}
const client = await db.client.findFirst({
@@ -18,10 +16,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
})
if(!client) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Invalid client secret',
})
return next(createError(401, 'Invalid client secret'))
}
req.client = {

View File

@@ -0,0 +1,16 @@
import { NextFunction, Request, Response } from "express";
import { HttpError } from "../responses/errors";
import { MixanErrorResponse } from "@mixan/types";
export const errorHandler = (error: HttpError | Error, req: Request, res: Response, next: NextFunction) => {
if(error instanceof HttpError) {
return res.status(error.status).json(error.toJson())
}
return res.status(500).json({
code: 500,
status: 'error',
message: error.message || 'Unexpected error occured',
issues: []
} satisfies MixanErrorResponse);
};

View File

@@ -1,40 +1,38 @@
import {
MixanIssue,
MixanErrorResponse,
MixanIssuesResponse,
MixanErrorResponse
} from '@mixan/types'
export function issues(arr: Array<MixanIssue>): MixanIssuesResponse {
return {
issues: arr.map((item) => {
return {
field: item.field,
message: item.message,
value: item.value,
}
}),
export class HttpError extends Error {
public status: number
public message: string
public issues: MixanIssue[]
constructor(status: number, message: string | Error, issues?: MixanIssue[]) {
super(message instanceof Error ? message.message : message)
this.status = status
this.message = message instanceof Error ? message.message : message
this.issues = issues || []
}
toJson(): MixanErrorResponse {
return {
code: this.status,
status: 'error',
message: this.message,
issues: this.issues,
}
}
}
export function makeError(error: unknown): MixanErrorResponse {
if (error instanceof Error) {
return {
code: 'Error',
message: error.message,
}
}
// @ts-ignore
if ('message' in error) {
return {
code: 'UnknownError',
// @ts-ignore
message: error.message,
}
}
return {
code: 'UnknownError',
message: 'Unknown error',
}
export function createIssues(arr: Array<MixanIssue>) {
throw new HttpError(400, 'Issues', arr)
}
export function createError(status = 500, error: unknown | Error | string) {
if(error instanceof Error || typeof error === 'string') {
return new HttpError(status, error)
}
return new HttpError(500, 'Unexpected error occured')
}

View File

@@ -1,39 +1,41 @@
import {Router} from 'express'
import {NextFunction, Response, Router} from 'express'
import { db } from '../db';
import { MixanRequest } from '../types/express';
import { EventPayload } from '@mixan/types';
import { getEvents, getProfileIdFromEvents } from '../services/event';
import { getEvents } from '../services/event';
import { success } from '../responses/success';
import { makeError } from '../responses/errors';
const router = Router();
type PostRequest = MixanRequest<Array<EventPayload>>
router.get('/events', async (req, res) => {
router.get('/events', async (req, res, next) => {
try {
const events = await getEvents(req.client.project_id)
res.json(success(events))
} catch (error) {
res.json(makeError(error))
next(error)
}
})
router.post('/events', async (req: PostRequest, res) => {
const projectId = req.client.project_id
const profileId = await getProfileIdFromEvents(projectId, req.body)
router.post('/events', async (req: PostRequest, res: Response, next: NextFunction) => {
try {
const projectId = req.client.project_id
await db.event.createMany({
data: req.body.map((event) => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: event.profileId,
}))
})
await db.event.createMany({
data: req.body.map(event => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: profileId,
}))
})
res.status(201).json(success())
res.status(201).json(success())
} catch (error) {
next(error)
}
})
export default router

View File

@@ -1,132 +1,131 @@
import { Router } from 'express'
import { NextFunction, Response, Router } from 'express'
import { db } from '../db'
import { MixanRequest } from '../types/express'
import {
createProfile,
getProfileByExternalId,
updateProfile,
} from '../services/profile'
import { getProfile, tickProfileProperty } from '../services/profile'
import {
ProfileDecrementPayload,
ProfileIncrementPayload,
ProfilePayload,
} from '@mixan/types'
import { issues } from '../responses/errors'
import { success } from '../responses/success'
import randomAnimalName from 'random-animal-name'
const router = Router()
type PostRequest = MixanRequest<ProfilePayload>
router.get('/profiles', async (req, res) => {
res.json(success(await db.profile.findMany({
where: {
project_id: req.client.project_id,
},
})))
})
router.post('/profiles', async (req: PostRequest, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) {
await updateProfile(projectId, body.id, body, profile)
} else {
await createProfile(projectId, body)
router.get('/profiles', async (req, res, next) => {
try {
res.json(
success(
await db.profile.findMany({
where: {
project_id: req.client.project_id,
},
})
)
)
} catch (error) {
next(error)
}
res.status(profile ? 200 : 201).json(success())
})
router.post(
'/profiles/increment',
async (req: MixanRequest<ProfileIncrementPayload>, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) {
const existingProperties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value =
body.name in existingProperties ? existingProperties[body.name] : 0
const properties = {
...existingProperties,
[body.name]: value + body.value,
}
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
await db.profile.updateMany({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
},
'/profiles',
async (
req: MixanRequest<{
id: string
properties?: Record<string, any>
}>,
res: Response,
next: NextFunction
) => {
try {
const projectId = req.client.project_id
const { id, properties } = req.body
const profile = await db.profile.create({
data: {
properties,
id,
external_id: null,
email: null,
first_name: randomAnimalName(),
last_name: null,
avatar: null,
properties: {
...(properties || {}),
},
project_id: projectId,
},
})
}
res.status(200).json(success())
res.status(201).json(success(profile))
} catch (error) {
next(error)
}
}
)
router.post(
'/profiles/decrement',
async (req: MixanRequest<ProfileDecrementPayload>, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
router.put('/profiles/:id', async (req: PostRequest, res: Response, next: NextFunction) => {
try {
const profileId = req.params.id
const profile = await getProfile(profileId)
const { body } = req
if (profile) {
const existingProperties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value =
body.name in existingProperties ? existingProperties[body.name] : 0
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
const properties = {
...existingProperties,
[body.name]: value - body.value,
}
await db.profile.updateMany({
await db.profile.update({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
id: profileId,
},
data: {
properties,
external_id: body.id,
email: body.email,
first_name: body.first_name,
last_name: body.last_name,
avatar: body.avatar,
properties: {
...(typeof profile.properties === 'object'
? profile.properties || {}
: {}),
...(body.properties || {}),
},
},
})
}
res.status(200).json(success())
res.status(200).json(success())
}
} catch (error) {
next(error)
}
})
router.put(
'/profiles/:id/increment',
async (req: MixanRequest<ProfileIncrementPayload>, res: Response, next: NextFunction) => {
try {
await tickProfileProperty({
name: req.body.name,
tick: req.body.value,
profileId: req.params.id,
})
res.status(200).json(success())
} catch (error) {
next(error)
}
}
)
router.put(
'/profiles/:id/decrement',
async (req: MixanRequest<ProfileDecrementPayload>, res: Response, next: NextFunction) => {
try {
await tickProfileProperty({
name: req.body.name,
tick: -Math.abs(req.body.value),
profileId: req.params.id,
})
res.status(200).json(success())
} catch (error) {
next(error)
}
}
)

View File

@@ -1,9 +1,8 @@
import { Request, Response } from 'express'
import { NextFunction, Request, Response } from 'express'
import { db } from '../db'
import { makeError } from '../responses/errors'
import { v4 as uuid } from 'uuid'
export async function setup(req: Request, res: Response) {
export async function setup(req: Request, res: Response, next: NextFunction) {
try {
const organization = await db.organization.create({
data: {
@@ -22,7 +21,7 @@ export async function setup(req: Request, res: Response) {
data: {
name: 'Acme Website Client',
project_id: project.id,
secret: uuid(),
secret: '4bfc4a0b-37e0-4916-b634-95c6a32a2e77',
},
})
@@ -32,6 +31,6 @@ export async function setup(req: Request, res: Response) {
client,
})
} catch (error) {
res.json(makeError(error))
next(error)
}
}

View File

@@ -1,4 +1,3 @@
import { EventPayload } from "@mixan/types";
import { db } from "../db";
export function getEvents(projectId: string) {
@@ -8,22 +7,3 @@ export function getEvents(projectId: string) {
}
})
}
export async function getProfileIdFromEvents(projectId: string, events: EventPayload[]) {
const event = events.find(item => !!item.externalId)
if(event?.externalId) {
return db.profile.findUnique({
where: {
project_id_external_id: {
project_id: projectId,
external_id: event.externalId,
}
}
}).then((res) => {
return res?.id || null
}).catch(() => {
return null
})
}
return null
}

View File

@@ -1,7 +0,0 @@
export async function hashPassword(password: string) {
return await Bun.password.hash(password);
}
export async function verifyPassword(password: string,hashedPassword: string) {
return await Bun.password.verify(password, hashedPassword);
}

View File

@@ -1,32 +1,15 @@
import { EventPayload, ProfilePayload } from "@mixan/types";
import { db } from "../db";
import { Prisma } from "@prisma/client";
import { EventPayload, ProfilePayload } from '@mixan/types'
import { db } from '../db'
import { Prisma } from '@prisma/client'
import { HttpError } from '../responses/errors'
export function createProfile(projectId: string, payload: ProfilePayload) {
const { id, email, first_name, last_name, avatar, properties } = payload
return db.profile.create({
data:{
external_id: id,
email,
first_name,
last_name,
avatar,
properties: properties || {},
project_id: projectId,
}
})
}
type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfile>, null>
type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfileByExternalId>, null>
export function getProfileByExternalId(projectId: string, externalId: string) {
return db.profile.findUnique({
export function getProfile(id: string) {
return db.profile.findUniqueOrThrow({
where: {
project_id_external_id: {
project_id: projectId,
external_id: externalId,
}
}
id,
},
})
}
@@ -34,42 +17,47 @@ export function getProfiles(projectId: string) {
return db.profile.findMany({
where: {
project_id: projectId,
}
},
})
}
export async function updateProfile(projectId: string, profileId: string, payload: Omit<ProfilePayload, 'id'>, oldProfile: DbProfile) {
const { email, first_name, last_name, avatar, properties } = payload
return db.profile.update({
export async function tickProfileProperty({
profileId,
tick,
name,
}: {
profileId: string
tick: number
name: string
}) {
const profile = await getProfile(profileId)
if (!profile) {
throw new HttpError(404, `Profile not found ${profileId}`)
}
const properties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value = name in properties ? properties[name] : 0
if (typeof value !== 'number') {
throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`)
}
if (typeof tick !== 'number') {
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`)
}
await db.profile.update({
where: {
project_id_external_id: {
project_id: projectId,
external_id: profileId,
}
id: profileId,
},
data: {
email,
first_name,
last_name,
avatar,
properties: {
...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}),
...(properties || {}),
...properties,
[name]: value + tick,
},
},
})
}
export async function getInternalProfileId(profileId?: string | null) {
if(!profileId) {
return null
}
const profile = await db.profile.findFirst({
where: {
external_id: profileId,
}
})
return profile?.id || null
}

View File

@@ -1,3 +1,5 @@
export type MixanRequest<Body> = Omit<Express.Request,'body'> & {
import { Request } from "express"
export type MixanRequest<Body> = Omit<Request,'body'> & {
body: Body
}