wip: docker
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Ready for docker-compose
|
||||
REDIS_URL="redis://127.0.0.1:6379"
|
||||
DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable"
|
||||
NEXTAUTH_SECRET="secret_sauce"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
54
README.md
54
README.md
@@ -69,39 +69,35 @@ For pushing events
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
import { Mixan } from '@mixan/sdk';
|
||||
import { MixanWeb } from '@mixan/sdk-web';
|
||||
|
||||
const mixan = new Mixan({
|
||||
// import { MixanNative } from '@mixan/sdk-native';
|
||||
|
||||
const mixan = new MixanWeb({
|
||||
clientId: 'uuid',
|
||||
clientSecret: 'uuid',
|
||||
url: 'http://localhost:8080/api/sdk',
|
||||
batchInterval: 10000,
|
||||
verbose: false,
|
||||
saveProfileId(id) {
|
||||
// Web
|
||||
localStorage.setItem('@profileId', id);
|
||||
// // react-native-mmkv
|
||||
// mmkv.setItem('@profileId', id)
|
||||
},
|
||||
removeProfileId() {
|
||||
// Web
|
||||
localStorage.removeItem('@profileId');
|
||||
// // react-native-mmkv
|
||||
// mmkv.delete('@profileId')
|
||||
},
|
||||
getProfileId() {
|
||||
// Web
|
||||
return localStorage.getItem('@profileId');
|
||||
// // react-native-mmkv
|
||||
// return mmkv.getString('@profileId')
|
||||
},
|
||||
trackIp: true,
|
||||
});
|
||||
|
||||
// const mixan = new MixanNative({
|
||||
// clientId: 'uuid',
|
||||
// clientSecret: 'uuid',
|
||||
// url: 'http://localhost:8080/api/sdk',
|
||||
// batchInterval: 10000,
|
||||
// verbose: false,
|
||||
// trackIp: true,
|
||||
// });
|
||||
|
||||
// Call this before you send any events
|
||||
// It will create a anonymous profile
|
||||
// This profile will be merged if you call `setUser` in a later stage
|
||||
mixan.init();
|
||||
|
||||
// tracks all outgoing links as a `link_out` event
|
||||
mixan.trackOutgoingLinks();
|
||||
|
||||
mixan.setUser({
|
||||
id: 'id',
|
||||
first_name: 'John',
|
||||
@@ -125,10 +121,13 @@ 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
|
||||
// Screen view for web
|
||||
mixan.screenView();
|
||||
|
||||
// Screen view for native
|
||||
mixan.screenView('Article', {
|
||||
id: '3',
|
||||
title: 'Nice article here',
|
||||
});
|
||||
|
||||
// Call this when a user is logged out.
|
||||
@@ -150,6 +149,11 @@ We use https://cron-job.org (free) to handle our cronjobs, you can use any provi
|
||||
- **https://domain.com/api/cron/cache/update** Will update the memory cache
|
||||
- **https://domain.com/api/cron/events/enrich** Enrich events (adds duration etc)
|
||||
|
||||
## Development
|
||||
|
||||
1. Run `docker-compose up -d` to get redis and postgres running
|
||||
2. Then `pnpm dev` to boot the web and worker (queue)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3002",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -2,10 +2,13 @@ import { MixanWeb } from '@mixan-test/sdk-web';
|
||||
|
||||
export const mixan = new MixanWeb({
|
||||
verbose: true,
|
||||
url: process.env.NEXT_PUBLIC_MIXAN_URL!,
|
||||
clientId: process.env.NEXT_PUBLIC_MIXAN_CLIENT_ID!,
|
||||
clientSecret: process.env.NEXT_PUBLIC_MIXAN_CLIENT_SECRET!,
|
||||
url: 'http://localhost:3000/api/sdk',
|
||||
clientId: '568b4ed1-5d00-4f27-88a7-b8959e6674bd',
|
||||
clientSecret: '1e362905-d352-44c4-9263-e037a2ad52fb',
|
||||
trackIp: true,
|
||||
});
|
||||
|
||||
mixan.init({
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
mixan.trackOutgoingLinks();
|
||||
|
||||
@@ -3,11 +3,10 @@ import { mixan } from '@/analytics';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
mixan.init();
|
||||
|
||||
export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
mixan.screenView();
|
||||
return router.events.on('routeChangeComplete', () => {
|
||||
mixan.screenView();
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="file:./db.sqlite"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# Next Auth Discord Provider
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
@@ -7,9 +7,13 @@ await import('./src/env.mjs');
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [],
|
||||
transpilePackages: ['@mixan/queue'],
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
experimental: {
|
||||
// Avoid "Critical dependency: the request of a dependency is an expression"
|
||||
serverComponentsExternalPackages: ['bullmq'],
|
||||
},
|
||||
/**
|
||||
* If you are using `appDir` then you must comment the below `i18n` config out.
|
||||
*
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"dev": "next dev",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"**/*.{tsx,mjs,ts,md,json}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@mixan/db": "workspace:^",
|
||||
"@mixan/queue": "workspace:^",
|
||||
"@mixan/types": "workspace:*",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -83,7 +83,6 @@
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.1",
|
||||
"prisma": "^5.1.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { formatDate } from '@/utils/date';
|
||||
import type { Project as IProject } from '@prisma/client';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import type { Project as IProject } from '@mixan/db';
|
||||
|
||||
import { ProjectActions } from './ProjectActions';
|
||||
|
||||
export type Project = IProject;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useRefetchActive } from '@/hooks/useRefetchActive';
|
||||
@@ -144,8 +143,12 @@ export default function CreateProject() {
|
||||
name="withCors"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2 text-sm font-medium leading-none mb-4">
|
||||
<label
|
||||
htmlFor="cors"
|
||||
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
|
||||
>
|
||||
<Checkbox
|
||||
id="cors"
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { validateSdkRequest } from '@/server/auth';
|
||||
import { db } from '@/server/db';
|
||||
import { createError, handleError } from '@/server/exceptions';
|
||||
import { tickProfileProperty } from '@/server/services/profile.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
|
||||
import { eventsQueue } from '@mixan/queue';
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
@@ -28,207 +25,13 @@ export default async function handler(req: Request, res: NextApiResponse) {
|
||||
return handleError(res, createError(405, 'Method not allowed'));
|
||||
}
|
||||
|
||||
const time = Date.now();
|
||||
|
||||
try {
|
||||
// Check client id & secret
|
||||
const projectId = await validateSdkRequest(req, res);
|
||||
|
||||
const profileIds = new Set<string>(
|
||||
req.body
|
||||
.map((item) => item.payload.profileId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
);
|
||||
|
||||
if (profileIds.size === 0) {
|
||||
return res.status(400).json({ status: 'error' });
|
||||
}
|
||||
|
||||
const profiles = await db.profile.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(profileIds),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-inner-declarations
|
||||
async function getProfile(profileId: string) {
|
||||
const profile = profiles.find((profile) => profile.id === profileId);
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const created = await db.profile.create({
|
||||
data: {
|
||||
id: profileId,
|
||||
properties: {},
|
||||
project_id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
profiles.push(created);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
const mergedBody: BatchPayload[] = req.body.reduce((acc, item) => {
|
||||
const canMerge =
|
||||
item.type === 'update_profile' || item.type === 'update_session';
|
||||
|
||||
if (!canMerge) {
|
||||
return [...acc, item];
|
||||
}
|
||||
|
||||
const match = acc.findIndex(
|
||||
(i) =>
|
||||
i.type === item.type && i.payload.profileId === item.payload.profileId
|
||||
);
|
||||
|
||||
if (acc[match]) {
|
||||
acc[match]!.payload = mergeDeepRight(acc[match]!.payload, item.payload);
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as BatchPayload[]);
|
||||
|
||||
const failedEvents: BatchPayload[] = [];
|
||||
|
||||
for (const item of mergedBody) {
|
||||
try {
|
||||
const { type, payload } = item;
|
||||
const profile = await getProfile(payload.profileId);
|
||||
switch (type) {
|
||||
case 'create_profile': {
|
||||
profile.properties = {
|
||||
...(typeof profile.properties === 'object'
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(payload.properties ?? {}),
|
||||
};
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update_profile': {
|
||||
profile.properties = {
|
||||
...(typeof profile.properties === 'object'
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(payload.properties ?? {}),
|
||||
};
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
external_id: payload.id,
|
||||
email: payload.email,
|
||||
first_name: payload.first_name,
|
||||
last_name: payload.last_name,
|
||||
avatar: payload.avatar,
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'set_profile_property': {
|
||||
if (
|
||||
typeof (profile.properties as Record<string, unknown>)[
|
||||
payload.name
|
||||
] === 'undefined'
|
||||
) {
|
||||
(profile.properties as Record<string, unknown>)[payload.name] =
|
||||
payload.value;
|
||||
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
// @ts-expect-error
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
await tickProfileProperty({
|
||||
profileId: payload.profileId,
|
||||
name: payload.name,
|
||||
tick: payload.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
await tickProfileProperty({
|
||||
profileId: payload.profileId,
|
||||
name: payload.name,
|
||||
tick: -Math.abs(payload.value),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
await db.event.create({
|
||||
data: {
|
||||
name: payload.name,
|
||||
properties: payload.properties,
|
||||
createdAt: payload.time,
|
||||
project_id: projectId,
|
||||
profile_id: payload.profileId,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update_session': {
|
||||
const session = await db.event.findFirst({
|
||||
where: {
|
||||
profile_id: payload.profileId,
|
||||
project_id: projectId,
|
||||
name: 'session_start',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (session) {
|
||||
await db.$executeRawUnsafe(
|
||||
`UPDATE events SET properties = '${JSON.stringify(
|
||||
payload.properties
|
||||
)}' || properties WHERE "createdAt" >= '${session.createdAt.toISOString()}' AND profile_id = '${
|
||||
payload.profileId
|
||||
}'`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to create "${item.type}"`);
|
||||
console.log(' > Payload:', item.payload);
|
||||
console.log(' > Error:', error);
|
||||
failedEvents.push(item);
|
||||
}
|
||||
} // end for
|
||||
|
||||
await db.eventFailed.createMany({
|
||||
data: failedEvents.map((item) => ({
|
||||
data: item as Record<string, any>,
|
||||
})),
|
||||
});
|
||||
|
||||
console.log('Batch took', Date.now() - time, 'ms', {
|
||||
events: req.body.length,
|
||||
combined: mergedBody.length,
|
||||
await eventsQueue.add('batch', {
|
||||
projectId,
|
||||
payload: req.body,
|
||||
});
|
||||
|
||||
res.status(200).json({ status: 'ok' });
|
||||
|
||||
@@ -4,14 +4,10 @@ import { createError, handleError } from '@/server/exceptions';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import randomAnimalName from 'random-animal-name';
|
||||
|
||||
import type {
|
||||
CreateProfilePayload,
|
||||
CreateProfileResponse,
|
||||
ProfilePayload,
|
||||
} from '@mixan/types';
|
||||
import type { CreateProfileResponse, ProfilePayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: ProfilePayload | CreateProfilePayload;
|
||||
body: ProfilePayload;
|
||||
}
|
||||
|
||||
export default async function handler(req: Request, res: NextApiResponse) {
|
||||
|
||||
32
apps/web/src/pages/api/sdk/test.ts
Normal file
32
apps/web/src/pages/api/sdk/test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { eventsQueue } from '@mixan/queue';
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
body: BatchPayload[];
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function handler(req: Request, res: NextApiResponse) {
|
||||
eventsQueue.add('batch', {
|
||||
payload: [
|
||||
{
|
||||
type: 'event',
|
||||
payload: {
|
||||
profileId: 'f8235c6a-c720-4f38-8f6c-b6b7d31e16db',
|
||||
name: 'test',
|
||||
properties: {},
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
projectId: 'b725eadb-a1fe-4be8-bf0b-9d9bfa6aac12',
|
||||
});
|
||||
res.status(200).json({ status: 'ok' });
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const clientRouter = createTRPCRouter({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
cors: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
@@ -50,6 +51,7 @@ export const clientRouter = createTRPCRouter({
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
cors: input.cors,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -3,10 +3,11 @@ import { db } from '@/server/db';
|
||||
import { getDashboardBySlug } from '@/server/services/dashboard.service';
|
||||
import { getProjectBySlug } from '@/server/services/project.service';
|
||||
import { slug } from '@/utils/slug';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaError } from 'prisma-error-enum';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Prisma } from '@mixan/db';
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
|
||||
@@ -11,9 +11,10 @@ import type {
|
||||
} from '@/types';
|
||||
import { alphabetIds, timeRanges } from '@/utils/constants';
|
||||
import { zChartInput } from '@/utils/validation';
|
||||
import type { Report as DbReport } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Report as DbReport } from '@mixan/db';
|
||||
|
||||
function transformFilter(
|
||||
filter: Partial<IChartEventFilter>,
|
||||
index: number
|
||||
@@ -48,7 +49,7 @@ function transformReport(report: DbReport): IChartInput & { id: string } {
|
||||
chartType: report.chart_type,
|
||||
interval: report.interval,
|
||||
name: report.name || 'Untitled',
|
||||
range: report.range as IChartRange ?? timeRanges['1m'],
|
||||
range: (report.range as IChartRange) ?? timeRanges['1m'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,16 @@ export async function validateSdkRequest(
|
||||
throw createError(401, 'Invalid client secret');
|
||||
}
|
||||
} else if (client.cors !== '*') {
|
||||
res.setHeader('Access-Control-Allow-Origin', client.cors);
|
||||
const ok = client.cors.split(',').find((origin) => {
|
||||
if (origin === req.headers.origin) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (ok) {
|
||||
res.setHeader('Access-Control-Allow-Origin', String(req.headers.origin));
|
||||
} else {
|
||||
throw createError(401, 'Invalid cors settings');
|
||||
}
|
||||
}
|
||||
|
||||
return client.project_id;
|
||||
|
||||
@@ -1,14 +1 @@
|
||||
import { env } from '@/env.mjs';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['error'],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
export { db } from '@mixan/db';
|
||||
|
||||
@@ -8,10 +8,11 @@ import type {
|
||||
zChartType,
|
||||
zTimeInterval,
|
||||
} from '@/utils/validation';
|
||||
import type { Client, Project } from '@prisma/client';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { Client, Project } from '@mixan/db';
|
||||
|
||||
export type HtmlProps<T> = Omit<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<T>, T>,
|
||||
'ref'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Profile } from '@prisma/client';
|
||||
import type { Profile } from '@mixan/db';
|
||||
|
||||
export function getProfileName(profile: Profile | undefined | null) {
|
||||
if (!profile) return '';
|
||||
|
||||
40
apps/worker/package.json
Normal file
40
apps/worker/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@mixan/worker",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
|
||||
"start": "node dist/index.js",
|
||||
"build": "rm -rf dist && tsup",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^5.13.0",
|
||||
"@bull-board/express": "^5.13.0",
|
||||
"@mixan/db": "workspace:*",
|
||||
"@mixan/queue": "workspace:*",
|
||||
"bullmq": "^5.1.1",
|
||||
"express": "^4.18.2",
|
||||
"ramda": "^0.29.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mixan/eslint-config": "workspace:*",
|
||||
"@mixan/prettier-config": "workspace:*",
|
||||
"@mixan/tsconfig": "workspace:*",
|
||||
"@mixan/types": "workspace:*",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@mixan/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@mixan/prettier-config"
|
||||
}
|
||||
29
apps/worker/src/index.ts
Normal file
29
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { Worker } from 'bullmq';
|
||||
import express from 'express';
|
||||
|
||||
import { connection, eventsQueue } from '@mixan/queue';
|
||||
|
||||
import { eventsJob } from './jobs/events';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/');
|
||||
const app = express();
|
||||
|
||||
new Worker(eventsQueue.name, eventsJob, {
|
||||
connection,
|
||||
});
|
||||
|
||||
createBullBoard({
|
||||
queues: [new BullMQAdapter(eventsQueue)],
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
app.use('/', serverAdapter.getRouter());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`For the UI, open http://localhost:${PORT}/`);
|
||||
});
|
||||
250
apps/worker/src/jobs/events.ts
Normal file
250
apps/worker/src/jobs/events.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Job } from 'bullmq';
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
|
||||
import { db } from '@mixan/db';
|
||||
import type { EventsQueuePayload } from '@mixan/queue/src/queues';
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
export async function eventsJob(job: Job<EventsQueuePayload>) {
|
||||
const projectId = job.data.projectId;
|
||||
const body = job.data.payload;
|
||||
|
||||
const profileIds = new Set<string>(
|
||||
body
|
||||
.map((item) => item.payload.profileId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
);
|
||||
|
||||
if (profileIds.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profiles = await db.profile.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(profileIds),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function getProfile(profileId: string) {
|
||||
const profile = profiles.find((profile) => profile.id === profileId);
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const created = await db.profile.create({
|
||||
data: {
|
||||
id: profileId,
|
||||
properties: {},
|
||||
project_id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
profiles.push(created);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
const mergedBody: BatchPayload[] = body.reduce((acc, item) => {
|
||||
const canMerge =
|
||||
item.type === 'update_profile' || item.type === 'update_session';
|
||||
|
||||
if (!canMerge) {
|
||||
return [...acc, item];
|
||||
}
|
||||
|
||||
const match = acc.findIndex(
|
||||
(i) =>
|
||||
i.type === item.type && i.payload.profileId === item.payload.profileId
|
||||
);
|
||||
|
||||
if (acc[match]) {
|
||||
acc[match]!.payload = mergeDeepRight(acc[match]!.payload, item.payload);
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as BatchPayload[]);
|
||||
|
||||
const failedEvents: BatchPayload[] = [];
|
||||
|
||||
for (const item of mergedBody) {
|
||||
try {
|
||||
const { type, payload } = item;
|
||||
const profile = await getProfile(payload.profileId);
|
||||
switch (type) {
|
||||
case 'create_profile': {
|
||||
profile.properties = {
|
||||
...(typeof profile.properties === 'object'
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(payload.properties ?? {}),
|
||||
};
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update_profile': {
|
||||
profile.properties = {
|
||||
...(typeof profile.properties === 'object'
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(payload.properties ?? {}),
|
||||
};
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
external_id: payload.id,
|
||||
email: payload.email,
|
||||
first_name: payload.first_name,
|
||||
last_name: payload.last_name,
|
||||
avatar: payload.avatar,
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'set_profile_property': {
|
||||
if (
|
||||
typeof (profile.properties as Record<string, unknown>)[
|
||||
payload.name
|
||||
] === 'undefined'
|
||||
) {
|
||||
(profile.properties as Record<string, unknown>)[payload.name] =
|
||||
payload.value;
|
||||
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: payload.profileId,
|
||||
},
|
||||
data: {
|
||||
// @ts-expect-error
|
||||
properties: profile.properties,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
await tickProfileProperty({
|
||||
profileId: payload.profileId,
|
||||
name: payload.name,
|
||||
tick: payload.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
await tickProfileProperty({
|
||||
profileId: payload.profileId,
|
||||
name: payload.name,
|
||||
tick: -Math.abs(payload.value),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
await db.event.create({
|
||||
data: {
|
||||
name: payload.name,
|
||||
properties: payload.properties,
|
||||
createdAt: payload.time,
|
||||
project_id: projectId,
|
||||
profile_id: payload.profileId,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update_session': {
|
||||
const session = await db.event.findFirst({
|
||||
where: {
|
||||
profile_id: payload.profileId,
|
||||
project_id: projectId,
|
||||
name: 'session_start',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (session) {
|
||||
await db.$executeRawUnsafe(
|
||||
`UPDATE events SET properties = '${JSON.stringify(
|
||||
payload.properties
|
||||
)}' || properties WHERE "createdAt" >= '${session.createdAt.toISOString()}' AND profile_id = '${
|
||||
payload.profileId
|
||||
}'`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
job.log(`Failed to create "${item.type}"`);
|
||||
job.log(` > Payload: ${JSON.stringify(item.payload)}`);
|
||||
if (error instanceof Error) {
|
||||
job.log(` > Error: ${error.message.trim()}`);
|
||||
job.log(` > Stack: ${error.stack}`);
|
||||
}
|
||||
failedEvents.push(item);
|
||||
job.log(`---`);
|
||||
}
|
||||
} // end for
|
||||
|
||||
await db.eventFailed.createMany({
|
||||
data: failedEvents.map((item) => ({
|
||||
data: item as Record<string, any>,
|
||||
})),
|
||||
});
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function tickProfileProperty({
|
||||
profileId,
|
||||
tick,
|
||||
name,
|
||||
}: {
|
||||
profileId: string;
|
||||
tick: number;
|
||||
name: string;
|
||||
}) {
|
||||
const profile = await db.profile.findUniqueOrThrow({
|
||||
where: {
|
||||
id: 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') {
|
||||
return `Property "${name}" on user is of type ${typeof value}`;
|
||||
}
|
||||
|
||||
if (typeof tick !== 'number') {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
return `Value is not a number ${tick} (${typeof tick})`;
|
||||
}
|
||||
|
||||
await db.profile.update({
|
||||
where: {
|
||||
id: profileId,
|
||||
},
|
||||
data: {
|
||||
properties: {
|
||||
...properties,
|
||||
[name]: value + tick,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
12
apps/worker/tsconfig.json
Normal file
12
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@mixan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
17
apps/worker/tsup.config.ts
Normal file
17
apps/worker/tsup.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import type { Options } from 'tsup';
|
||||
|
||||
const options: Options = {
|
||||
clean: true,
|
||||
entry: ['src/index.ts'],
|
||||
noExternal: [/^@mixan\/.*$/u, /^@\/.*$/u],
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
};
|
||||
|
||||
if (process.env.WATCH) {
|
||||
options.watch = ['src/**/*', '../../packages/**/*'];
|
||||
options.onSuccess = 'node dist/index.js';
|
||||
}
|
||||
|
||||
export default defineConfig(options);
|
||||
10
build_docker
10
build_docker
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker build \
|
||||
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
|
||||
--build-arg NEXTAUTH_SECRET="secret_sauce" \
|
||||
--build-arg NEXTAUTH_URL="http://localhost:3000" \
|
||||
-t mixan/app:latest \
|
||||
-t mixan/app:1.0 \
|
||||
-f apps/web/Dockerfile \
|
||||
.
|
||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_USER: username
|
||||
ports:
|
||||
- 5435:5432
|
||||
restart: on-failure:3
|
||||
pgweb:
|
||||
image: sosedoff/pgweb
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
PGWEB_DATABASE_URL: postgres://username:password@postgres:5432/postgres?sslmode=disable
|
||||
ports:
|
||||
- 8085:8081
|
||||
restart: on-failure:3
|
||||
redis:
|
||||
image: redis:latest
|
||||
command: redis-server
|
||||
volumes:
|
||||
- redis:/var/lib/redis
|
||||
- redis-config:/usr/local/etc/redis/redis.conf
|
||||
ports:
|
||||
- 6379:6379
|
||||
networks:
|
||||
- redis-network
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
- HTTP_USER=root
|
||||
- HTTP_PASSWORD=qwerty
|
||||
ports:
|
||||
- 8081:8081
|
||||
networks:
|
||||
- redis-network
|
||||
depends_on:
|
||||
- redis
|
||||
volumes:
|
||||
redis:
|
||||
redis-config:
|
||||
networks:
|
||||
redis-network:
|
||||
driver: bridge
|
||||
103
docker/Dockerfile-composed
Normal file
103
docker/Dockerfile-composed
Normal file
@@ -0,0 +1,103 @@
|
||||
# Docker file that contains all the necessary services to run the app
|
||||
# EXCEPT the database. The database is run in a separate container.
|
||||
|
||||
# Services: web, worker (queue), redis
|
||||
# Excluded: db (postgres)
|
||||
|
||||
FROM --platform=linux/amd64 node:20-slim AS base
|
||||
|
||||
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG NEXTAUTH_SECRET="secret_sauce"
|
||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
|
||||
ARG NEXTAUTH_URL="http://localhost:3300"
|
||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
||||
|
||||
ARG REDIS_URL="redis://127.0.0.1:6379"
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl supervisor redis \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/types/package.json packages/types/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps apps
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN pnpm run build
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/web /app/apps/web
|
||||
COPY --from=build /app/apps/worker /app/apps/worker
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/web/node_modules /app/apps/web/node_modules
|
||||
COPY --from=prod /app/apps/worker/node_modules /app/apps/worker/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
RUN mkdir -p /var/log/supervisor /data
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/redis.conf /etc/redis/redis.conf
|
||||
|
||||
# Redis data
|
||||
VOLUME [ "/data" ]
|
||||
|
||||
EXPOSE 3000 3001
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
85
docker/Dockerfile-web
Normal file
85
docker/Dockerfile-web
Normal file
@@ -0,0 +1,85 @@
|
||||
# Dockerfile that builds the web app only
|
||||
|
||||
FROM --platform=linux/amd64 node:20-slim AS base
|
||||
|
||||
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG NEXTAUTH_SECRET="secret_sauce"
|
||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
|
||||
ARG NEXTAUTH_URL="http://localhost:3300"
|
||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
||||
|
||||
ARG REDIS_URL="redis://127.0.0.1:6379"
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/types/package.json packages/types/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps apps
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/web /app/apps/web
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/web/node_modules /app/apps/web/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
79
docker/Dockerfile-worker
Normal file
79
docker/Dockerfile-worker
Normal file
@@ -0,0 +1,79 @@
|
||||
# Dockerfile that builds the worker only
|
||||
|
||||
FROM --platform=linux/amd64 node:20-slim AS base
|
||||
|
||||
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG REDIS_URL="redis://127.0.0.1:6379"
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/types/package.json packages/types/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps apps
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/worker /app/apps/worker
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/worker/node_modules /app/apps/worker/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
11
docker/build-composed
Executable file
11
docker/build-composed
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker build \
|
||||
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
|
||||
--build-arg NEXTAUTH_SECRET="secret_sauce" \
|
||||
--build-arg NEXTAUTH_URL="http://localhost:3300" \
|
||||
--build-arg REDIS_URL="redis://127.0.0.1:6379" \
|
||||
-t mixan/composed:latest \
|
||||
-t mixan/composed:1.0 \
|
||||
-f docker/Dockerfile-composed \
|
||||
.
|
||||
1329
docker/redis.conf
Normal file
1329
docker/redis.conf
Normal file
File diff suppressed because it is too large
Load Diff
34
docker/supervisord.conf
Normal file
34
docker/supervisord.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
|
||||
[include]
|
||||
files = /etc/supervisor/conf.d/*.conf
|
||||
|
||||
[program:web]
|
||||
directory=/app/apps/web
|
||||
command=pnpm start
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
|
||||
[program:worker]
|
||||
directory=/app/apps/worker
|
||||
command=pnpm start
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
|
||||
[program:redis]
|
||||
command=redis-server /etc/redis/redis.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=root
|
||||
stdout_logfile=/var/log/redis/stdout.log
|
||||
stderr_logfile=/var/log/redis/stderr.log
|
||||
@@ -7,6 +7,9 @@
|
||||
"packageManager": "pnpm@8.7.6",
|
||||
"module": "index.ts",
|
||||
"scripts": {
|
||||
"db:codegen": "pnpm -r --filter db run codegen",
|
||||
"migrate": "pnpm -r --filter db run migrate",
|
||||
"migrate:deploy": "pnpm -r --filter db run migrate:deploy",
|
||||
"dev": "pnpm -r dev",
|
||||
"format": "pnpm -r format --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
|
||||
"format:fix": "pnpm -r format --write --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
|
||||
@@ -16,11 +19,10 @@
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"@mixan/prettier-config": "^0.1.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.2.2",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"prettier": "@mixan/prettier-config"
|
||||
|
||||
15
packages/db/index.ts
Normal file
15
packages/db/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export * from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
35
packages/db/package.json
Normal file
35
packages/db/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@mixan/db",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"codegen": "pnpm with-env prisma generate",
|
||||
"migrate": "pnpm with-env prisma migrate dev",
|
||||
"migrate:deploy": "pnpm with-env prisma migrate deploy",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mixan/eslint-config": "workspace:*",
|
||||
"@mixan/prettier-config": "workspace:*",
|
||||
"@mixan/tsconfig": "workspace:*",
|
||||
"@mixan/types": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prisma": "^5.1.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@mixan/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@mixan/prettier-config"
|
||||
}
|
||||
12
packages/db/tsconfig.json
Normal file
12
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@mixan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
2
packages/queue/index.ts
Normal file
2
packages/queue/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { eventsQueue } from './src/queues';
|
||||
export { connection } from './src/connection';
|
||||
30
packages/queue/package.json
Normal file
30
packages/queue/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mixan/queue",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bullmq": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mixan/eslint-config": "workspace:*",
|
||||
"@mixan/prettier-config": "workspace:*",
|
||||
"@mixan/tsconfig": "workspace:*",
|
||||
"@mixan/types": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@mixan/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@mixan/prettier-config"
|
||||
}
|
||||
10
packages/queue/src/connection.ts
Normal file
10
packages/queue/src/connection.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const parse = (connectionString: string) => {
|
||||
const url = new URL(connectionString);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port),
|
||||
password: url.password,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const connection = parse(String(process.env.REDIS_URL));
|
||||
17
packages/queue/src/queues.ts
Normal file
17
packages/queue/src/queues.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type { BatchPayload } from '@mixan/types';
|
||||
|
||||
import { connection } from './connection';
|
||||
|
||||
export interface EventsQueuePayload {
|
||||
projectId: string;
|
||||
payload: BatchPayload[];
|
||||
}
|
||||
|
||||
export const eventsQueue = new Queue<EventsQueuePayload>('events', {
|
||||
connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 10,
|
||||
},
|
||||
});
|
||||
12
packages/queue/tsconfig.json
Normal file
12
packages/queue/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@mixan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function getDevice() {
|
||||
ua
|
||||
);
|
||||
const t2 =
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
|
||||
ua.slice(0, 4)
|
||||
);
|
||||
if (t1 || t2) {
|
||||
|
||||
831
pnpm-lock.yaml
generated
831
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user