This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-11 12:34:35 +02:00
commit 903fd155c3
40 changed files with 1385 additions and 0 deletions

15
apps/backend/@types/express/index.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export {}
declare global {
// metadata-scraper relies on this type
type Element = any
// add context to request
namespace Express {
interface Request {
client: {
project_id: string
}
}
}
}

27
apps/backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "bun src/app.ts",
"dev": "bun --watch src/app.ts",
"codegen": "bunx prisma generate"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@mixan/types": "workspace:*",
"@prisma/client": "^5.4.2",
"express": "^4.18.2",
"morgan": "^1.10.0",
"prisma": "^5.4.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/express": "^4.17.18",
"@types/morgan": "^1.9.6",
"bun-types": "^1.0.5-canary.20231009T140142"
}
}

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "organizations" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "events" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "profiles" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "profiles_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "events" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "profiles" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `name` on the `profiles` table. All the data in the column will be lost.
- Added the required column `profile_id` to the `profiles` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "profiles" DROP COLUMN "name",
ADD COLUMN "profile_id" TEXT NOT NULL;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "profiles" ADD COLUMN "avatar" TEXT,
ADD COLUMN "email" TEXT,
ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "clients" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "clients" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `profile_id` to the `events` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "events" ADD COLUMN "profile_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "events" DROP CONSTRAINT "events_profile_id_fkey";
-- AlterTable
ALTER TABLE "events" ALTER COLUMN "profile_id" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `profile_id` on the `profiles` table. All the data in the column will be lost.
- Added the required column `external_id` to the `profiles` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "profiles" DROP COLUMN "profile_id",
ADD COLUMN "external_id" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[project_id,external_id]` on the table `profiles` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "profiles_project_id_external_id_key" ON "profiles"("project_id", "external_id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,100 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Organization {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
projects Project[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("organizations")
}
model Project {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
organization_id String @db.Uuid
organization Organization @relation(fields: [organization_id], references: [id])
events Event[]
profiles Profile[]
clients Client[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("projects")
}
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
email String
password String
organization_id String @db.Uuid
organization Organization @relation(fields: [organization_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("users")
}
model Event {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
properties Json
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
profile_id String? @db.Uuid
profile Profile? @relation(fields: [profile_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("events")
}
model Profile {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
external_id String
first_name String?
last_name String?
email String?
avatar String?
properties Json
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
events Event[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([project_id, external_id])
@@map("profiles")
}
model Client {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
secret String
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("clients")
}

41
apps/backend/src/app.ts Normal file
View File

@@ -0,0 +1,41 @@
import express from "express";
import events from './routes/events'
import profiles from './routes/profiles'
import { authMiddleware } from "./middlewares/auth";
import morgan from 'morgan'
import { db } from "./db";
import { hashPassword } from "./services/password";
import { v4 as uuid } from 'uuid';
const app = express();
const port = 8080;
app.use(morgan('tiny'))
app.use(express.json());
app.use(express.json());
app.use(authMiddleware)
app.use(events)
app.use(profiles)
app.get("/ping", (req, res) => res.json("pong"));
app.listen(port, () => {
console.log(`Listening on port ${port}...`);
});
// async function main() {
// const secret = uuid()
// await db.client.create({
// data: {
// project_id: 'eed345ae-2772-42e5-b989-e36e09c5febc',
// name: 'test',
// secret: await hashPassword(secret),
// }
// })
// console.log('Your secret is', secret);
// }
// main()

3
apps/backend/src/db.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();

View File

@@ -0,0 +1,33 @@
import { NextFunction, Request, Response } from "express"
import { db } from "../db"
import { verifyPassword } from "../services/password"
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',
})
}
const client = await db.client.findFirst({
where: {
secret,
},
})
if(!client) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Invalid client secret',
})
}
req.client = {
project_id: client.project_id,
}
next()
}

View File

@@ -0,0 +1,13 @@
import { MixanIssue, MixanIssuesResponse } 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,
};
})
}
}

View File

@@ -0,0 +1,8 @@
import { MixanResponse } from "@mixan/types";
export function success<T>(result?: T): MixanResponse<T | null> {
return {
result: result || null,
status: 'ok'
}
}

View File

@@ -0,0 +1,34 @@
import {Router} from 'express'
import { db } from '../db';
import { MixanRequest } from '../types/express';
import { EventPayload } from '@mixan/types';
import { getEvents, getProfileIdFromEvents } from '../services/event';
import { success } from '../responses/success';
const router = Router();
type PostRequest = MixanRequest<Array<EventPayload>>
router.get('/events', async (req, res) => {
const events = await getEvents(req.client.project_id)
res.json(success(events))
})
router.post('/events', async (req: PostRequest, res) => {
const projectId = req.client.project_id
const profileId = await getProfileIdFromEvents(projectId, req.body)
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())
})
export default router

View File

@@ -0,0 +1,133 @@
import { Router } from 'express'
import { db } from '../db'
import { MixanRequest } from '../types/express'
import {
createProfile,
getProfileByExternalId,
updateProfile,
} from '../services/profile'
import {
ProfileDecrementPayload,
ProfileIncrementPayload,
ProfilePayload,
} from '@mixan/types'
import { issues } from '../responses/errors'
import { success } from '../responses/success'
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)
}
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,
},
data: {
properties,
},
})
}
res.status(200).json(success())
}
)
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)
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({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
},
data: {
properties,
},
})
}
res.status(200).json(success())
}
)
export default router

View File

@@ -0,0 +1,29 @@
import { EventPayload } from "@mixan/types";
import { db } from "../db";
export function getEvents(projectId: string) {
return db.event.findMany({
where: {
project_id: projectId,
}
})
}
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

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,75 @@
import { EventPayload, ProfilePayload } from "@mixan/types";
import { db } from "../db";
import { Prisma } from "@prisma/client";
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 getProfileByExternalId>, null>
export function getProfileByExternalId(projectId: string, externalId: string) {
return db.profile.findUnique({
where: {
project_id_external_id: {
project_id: projectId,
external_id: externalId,
}
}
})
}
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({
where: {
project_id_external_id: {
project_id: projectId,
external_id: profileId,
}
},
data: {
email,
first_name,
last_name,
avatar,
properties: {
...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}),
...(properties || {}),
},
},
})
}
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

@@ -0,0 +1,3 @@
export type MixanRequest<Body> = Omit<Express.Request,'body'> & {
body: Body
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
}