commit 903fd155c3fe514a4b6aad694bacf6be7ae127b1 Author: Carl-Gerhard Lindesvärd Date: Wed Oct 11 12:34:35 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ab5afb29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 00000000..cadc9d2b --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# mixan + +Mixan is a simple analytics tool for logging events on web and react-native. My goal is to make a minimal mixpanel copy with the most basic features (for now). + +## @mixan/sdk + +For pushing events + +### Install + +- npm: `npm install @mixan/sdk` +- pnpm: `pnpm add @mixan/sdk` +- yarn: `yarn add @mixan/sdk` + +### Usage + +```ts +import { Mixan } from '@mixan/sdk'; + +const mixan = new Mixan({ + clientSecret: '9fb405d2-7e16-489f-980c-67b25a6eab97', + url: 'http://localhost:8080', + batchInterval: 10000, + verbose: false +}) + +mixan.setUser({ + id: 'id', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@gmail.com', + properties: {} // any properties +}) + +// will upsert 'app_open' on user property and increment it +mixan.increment('app_open') +// will upsert 'app_open' on user property and increment it by 10 +mixan.increment('app_open', 10) +// will upsert 'app_open' on user property and decrement it by 2 +mixan.decrement('app_open', 2) + +// send a sign_in event +mixan.event('sign_in') + +// send a sign_in event with properties +mixan.event('sign_in', { + provider: 'gmail' +}) + +// short hand for 'screen_view', can also take any properties +mixan.screenView('Profile', { + id: '123', + // any other properties, url, public +}) +``` + +## @mixan/backend + +Self hosted service for collecting all events. Dockerfile and GUI will be added soon. \ No newline at end of file diff --git a/apps/backend/@types/express/index.d.ts b/apps/backend/@types/express/index.d.ts new file mode 100644 index 00000000..35b994da --- /dev/null +++ b/apps/backend/@types/express/index.d.ts @@ -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 + } + } + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 00000000..b116d295 --- /dev/null +++ b/apps/backend/package.json @@ -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" + } +} diff --git a/apps/backend/prisma/migrations/20231010091416_init/migration.sql b/apps/backend/prisma/migrations/20231010091416_init/migration.sql new file mode 100644 index 00000000..3fb3b5d4 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010091416_init/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql b/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql new file mode 100644 index 00000000..81e72e4b --- /dev/null +++ b/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql b/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql new file mode 100644 index 00000000..1eca309e --- /dev/null +++ b/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql b/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql new file mode 100644 index 00000000..fdd80823 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql b/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql new file mode 100644 index 00000000..5d28ac9e --- /dev/null +++ b/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql b/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql new file mode 100644 index 00000000..1a9aea58 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql b/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql new file mode 100644 index 00000000..c8709ccc --- /dev/null +++ b/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql b/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql new file mode 100644 index 00000000..511459c8 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql b/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql new file mode 100644 index 00000000..3da7a1d7 --- /dev/null +++ b/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql b/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql new file mode 100644 index 00000000..eafa2140 --- /dev/null +++ b/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql @@ -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"); diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma new file mode 100644 index 00000000..0365e482 --- /dev/null +++ b/apps/backend/prisma/schema.prisma @@ -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") +} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 00000000..3777d175 --- /dev/null +++ b/apps/backend/src/app.ts @@ -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() \ No newline at end of file diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts new file mode 100644 index 00000000..dff3b3e9 --- /dev/null +++ b/apps/backend/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const db = new PrismaClient(); diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts new file mode 100644 index 00000000..d7517105 --- /dev/null +++ b/apps/backend/src/middlewares/auth.ts @@ -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() +} \ No newline at end of file diff --git a/apps/backend/src/responses/errors.ts b/apps/backend/src/responses/errors.ts new file mode 100644 index 00000000..3889abec --- /dev/null +++ b/apps/backend/src/responses/errors.ts @@ -0,0 +1,13 @@ +import { MixanIssue, MixanIssuesResponse } from "@mixan/types"; + +export function issues(arr: Array): MixanIssuesResponse { + return { + issues: arr.map((item) => { + return { + field: item.field, + message: item.message, + value: item.value, + }; + }) + } +} \ No newline at end of file diff --git a/apps/backend/src/responses/success.ts b/apps/backend/src/responses/success.ts new file mode 100644 index 00000000..94ce898f --- /dev/null +++ b/apps/backend/src/responses/success.ts @@ -0,0 +1,8 @@ +import { MixanResponse } from "@mixan/types"; + +export function success(result?: T): MixanResponse { + return { + result: result || null, + status: 'ok' + } +} \ No newline at end of file diff --git a/apps/backend/src/routes/events.ts b/apps/backend/src/routes/events.ts new file mode 100644 index 00000000..4d22a977 --- /dev/null +++ b/apps/backend/src/routes/events.ts @@ -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> + +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 \ No newline at end of file diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts new file mode 100644 index 00000000..2dafdc36 --- /dev/null +++ b/apps/backend/src/routes/profiles.ts @@ -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 + +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, 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 + 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, 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 + 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 diff --git a/apps/backend/src/services/event.ts b/apps/backend/src/services/event.ts new file mode 100644 index 00000000..3008e44f --- /dev/null +++ b/apps/backend/src/services/event.ts @@ -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 +} diff --git a/apps/backend/src/services/password.ts b/apps/backend/src/services/password.ts new file mode 100644 index 00000000..a084a350 --- /dev/null +++ b/apps/backend/src/services/password.ts @@ -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); +} \ No newline at end of file diff --git a/apps/backend/src/services/profile.ts b/apps/backend/src/services/profile.ts new file mode 100644 index 00000000..363d7854 --- /dev/null +++ b/apps/backend/src/services/profile.ts @@ -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, 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, 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 +} \ No newline at end of file diff --git a/apps/backend/src/types/express.ts b/apps/backend/src/types/express.ts new file mode 100644 index 00000000..f849b01e --- /dev/null +++ b/apps/backend/src/types/express.ts @@ -0,0 +1,3 @@ +export type MixanRequest = Omit & { + body: Body +} \ No newline at end of file diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 00000000..7556e1d4 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -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 + ] + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..9accf375 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 00000000..e5872eca --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mixan/root", + "version": "1.0.0", + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "module": "index.ts", + "type": "module", + "devDependencies": { + "bun-types": "latest", + "semver": "^7.5.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "tsup": "^7.2.0" + } +} \ No newline at end of file diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts new file mode 100644 index 00000000..352f089e --- /dev/null +++ b/packages/sdk/index.ts @@ -0,0 +1,191 @@ +import { + EventPayload, + MixanErrorResponse, + MixanIssuesResponse, + MixanResponse, + ProfilePayload, +} from '@mixan/types' + +type MixanOptions = { + url: string + clientSecret: string + batchInterval?: number + maxBatchSize?: number + verbose?: boolean +} + +class Fetcher { + private url: string + private clientSecret: string + private logger: (...args: any[]) => void + + constructor(options: MixanOptions) { + this.url = options.url + this.clientSecret = options.clientSecret + this.logger = options.verbose ? console.log : () => {} + } + + post(path: string, data: Record) { + const url = `${this.url}${path}` + this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2)) + return fetch(url, { + headers: { + ['mixan-client-secret']: this.clientSecret, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(data), + }) + .then(async (res) => { + const response = await res.json< + MixanIssuesResponse | MixanErrorResponse | MixanResponse + >() + if ('status' in response && response.status === 'ok') { + return response + } + + if ('code' in response) { + this.logger(`Mixan error: [${response.code}] ${response.message}`) + return null + } + + if ('issues' in response) { + this.logger(`Mixan issues:`) + response.issues.forEach((issue) => { + this.logger(` - ${issue.message} (${issue.value})`) + }) + + return null + } + + return null + }) + .catch(() => { + return null + }) + } +} + +class Batcher { + queue: T[] = [] + timer?: Timer + callback: (queue: T[]) => void + maxBatchSize = 10 + batchInterval = 10000 + + constructor(options: MixanOptions, callback: (queue: T[]) => void) { + this.callback = callback + + if (options.maxBatchSize) { + this.maxBatchSize = options.maxBatchSize + } + + if (options.batchInterval) { + this.batchInterval = options.batchInterval + } + } + + add(payload: T) { + this.queue.push(payload) + this.flush() + } + + flush() { + if (this.timer) { + clearTimeout(this.timer) + } + + if (this.queue.length === 0) { + return + } + + if (this.queue.length > this.maxBatchSize) { + this.send() + return + } + + this.timer = setTimeout(this.send.bind(this), this.batchInterval) + } + + send() { + this.callback(this.queue) + this.queue = [] + } +} + +export class Mixan { + private fetch: Fetcher + private eventBatcher: Batcher + private profile: ProfilePayload | null = null + + constructor(options: MixanOptions) { + this.fetch = new Fetcher(options) + this.eventBatcher = new Batcher(options, (queue) => { + this.fetch.post( + '/events', + queue.map((item) => ({ + ...item, + externalId: item.externalId || this.profile?.id, + })) + ) + }) + } + + timestamp() { + return new Date().toISOString() + } + + event(name: string, properties: Record) { + this.eventBatcher.add({ + name, + properties, + time: this.timestamp(), + externalId: this.profile?.id || null, + }) + } + + async setUser(profile: ProfilePayload) { + this.profile = profile + await this.fetch.post('/profiles', profile) + } + + async setUserProperty(name: string, value: any) { + await this.fetch.post('/profiles', { + ...this.profile, + properties: { + [name]: value, + }, + }) + } + + async increment(name: string, value: number = 1) { + if (!this.profile) { + return + } + + await this.fetch.post('/profiles/increment', { + id: this.profile.id, + name, + value, + }) + } + + async decrement(name: string, value: number = 1) { + if (!this.profile) { + return + } + + await this.fetch.post('/profiles/decrement', { + id: this.profile.id, + name, + value, + }) + } + + screenView(route: string, properties?: Record) { + this.event('screen_view', { + ...(properties || {}), + route, + }) + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..1670bd07 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,13 @@ +{ + "name": "@mixan/sdk", + "version": "0.0.1", + "type": "module", + "module": "index.ts", + "devDependencies": { + "@mixan/types": "workspace:*", + "bun-types": "latest" + }, + "dependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..fd6a7f5a --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + + "outDir": "dist", + "allowImportingTsExtensions": false, + "noEmit": false, + + "types": [ + "bun-types" // add Bun global + ], + } +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts new file mode 100644 index 00000000..045c25df --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["index.ts"], + format: ["cjs", "esm"], // Build for commonJS and ESmodules + dts: true, // Generate declaration file (.d.ts) + splitting: false, + sourcemap: true, + clean: true, +}); diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 00000000..95d6a0cf --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,15 @@ +# types + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.4. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/types/index.ts b/packages/types/index.ts new file mode 100644 index 00000000..00f756e1 --- /dev/null +++ b/packages/types/index.ts @@ -0,0 +1,74 @@ +export type MixanJson = Record + +export type EventPayload = { + name: string + time: string + externalId: string | null + properties: MixanJson +} + +export type ProfilePayload = { + first_name?: string + last_name?: string + email?: string + avatar?: string + id: string + properties: MixanJson +} + +export type ProfileIncrementPayload = { + name: string + value: number + id: string +} + +export type ProfileDecrementPayload = { + name: string + value: number + id: string +} + +// Batching +export type BatchEvent = { + type: 'event', + payload: EventPayload +} + +export type BatchProfile = { + type: 'profile', + payload: ProfilePayload +} + +export type BatchProfileIncrement = { + type: 'profile_increment', + payload: ProfileIncrementPayload +} + +export type BatchProfileDecrement = { + type: 'profile_decrement', + payload: ProfileDecrementPayload +} + +export type BatchItem = BatchEvent | BatchProfile | BatchProfileIncrement | BatchProfileDecrement +export type BatchPayload = Array + + +export type MixanIssue = { + field: string + message: string + value: any +} + +export type MixanIssuesResponse = { + issues: Array, +} + +export type MixanErrorResponse = { + code: string + message: string +} + +export type MixanResponse = { + result: T + status: 'ok' +} \ No newline at end of file diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 00000000..0ebdfd65 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mixan/types", + "version": "0.0.1", + "type": "module", + "module": "index.ts", + "devDependencies": { + "bun-types": "latest" + }, + "dependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 00000000..7556e1d4 --- /dev/null +++ b/packages/types/tsconfig.json @@ -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 + ] + } +} diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts new file mode 100644 index 00000000..9c84804e --- /dev/null +++ b/packages/types/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["index.ts"], + format: ["cjs", "esm"], // Build for commonJS and ESmodules + dts: true, // Generate declaration file (.d.ts) + splitting: false, + sourcemap: false, + clean: true, +}); diff --git a/publish.ts b/publish.ts new file mode 100644 index 00000000..a2106358 --- /dev/null +++ b/publish.ts @@ -0,0 +1,64 @@ +import sdkPkg from './packages/sdk/package.json' +import typesPkg from './packages/types/package.json' +import fs from 'node:fs' +import {execSync} from 'node:child_process' +import semver from 'semver' + +function savePackageJson(path: string, data: Record) { + fs.writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8') +} + +function main() { + const [version] = process.argv.slice(2) + + if(!version) { + return console.error('Missing version') + } + + if(!semver.valid(version)) { + return console.error('Version is not valid') + } + + const properties = { + private: false, + version, + type: 'module', + main: './dist/index.js', + module: './dist/index.mjs', + types: './dist/index.d.ts', + files: ['dist'], + } + + savePackageJson('./packages/sdk/package.json', { + ...sdkPkg, + ...properties, + dependencies: Object.entries(sdkPkg.dependencies).reduce( + (acc, [depName, depVersion]) => ({ + ...acc, + [depName]: depName.startsWith('@mixan') ? version : depVersion, + }), + {} + ), + }) + + savePackageJson('./packages/types/package.json', { + ...typesPkg, + ...properties, + }) + + execSync('bunx tsup', { + cwd: './packages/sdk', + }) + execSync('npm publish --access=public', { + cwd: './packages/sdk', + }) + + execSync('bunx tsup', { + cwd: './packages/types', + }) + execSync('npm publish --access=public', { + cwd: './packages/types', + }) +} + +main()