added tooling (eslint, typescript and prettier)

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-11-02 12:14:37 +01:00
parent 575b3c23bf
commit 493e1b7650
82 changed files with 1890 additions and 1363 deletions

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ dist
# Finder (MacOS) folder config
.DS_Store
*.tsbuildinfo

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors"
]
}

23
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"editor.codeActionsOnSave": { "source.fixAll.eslint": true },
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[handlebars]": {
"editor.formatOnSave": false,
"editor.codeActionsOnSave": { "source.fixAll.eslint": false }
},
"files.associations": { "*.hbs": "handlebars" },
"eslint.workingDirectories": [
{ "pattern": "apps/*/" },
{ "pattern": "packages/*/" },
{ "pattern": "tooling/*/" }
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib",
"[yaml]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.autoIndent": "advanced"
},
"editor.inlineSuggest.enabled": true
}

View File

@@ -13,4 +13,4 @@
"components": "@/components",
"utils": "@/utils/cn"
}
}
}

View File

@@ -2,7 +2,7 @@
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import("./src/env.mjs");
await import('./src/env.mjs');
/** @type {import("next").NextConfig} */
const config = {
@@ -14,8 +14,8 @@ const config = {
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
locales: ['en'],
defaultLocale: 'en',
},
};

View File

@@ -3,13 +3,13 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"db:push": "prisma db push",
"dev": "next dev",
"postinstall": "prisma generate",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"start": "next start"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
@@ -60,26 +60,32 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@types/bcrypt": "^5.0.0",
"@types/eslint": "^8.44.2",
"@types/node": "^18.16.0",
"@types/ramda": "^0.29.6",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.9",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.47.0",
"eslint": "^8.48.0",
"eslint-config-next": "^13.5.4",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.5.1",
"prisma": "^5.1.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
"typescript": "^5.2.0"
},
"ct3aMetadata": {
"initVersion": "7.21.0"
}
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base",
"@mixan/eslint-config/react"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -1,6 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
plugins: ['prettier-plugin-tailwindcss'],
};
export default config;

View File

@@ -1,3 +1,3 @@
export { default } from "next-auth/middleware"
export { default } from 'next-auth/middleware';
export const config = { matcher: ["/dashboard"] }
export const config = { matcher: ['/dashboard'] };

View File

@@ -1,9 +1,9 @@
import { useQueryParams } from "@/hooks/useQueryParams";
import { z } from "zod";
import { useQueryParams } from '@/hooks/useQueryParams';
import { z } from 'zod';
export const useReportId = () =>
useQueryParams(
z.object({
reportId: z.string().optional(),
}),
})
);

View File

@@ -1,24 +1,24 @@
import {
type IChartInput,
type IChartBreakdown,
type IChartEvent,
type IInterval,
type IChartType,
type IChartInput,
type IChartRange,
} from "@/types";
import { alphabetIds } from "@/utils/constants";
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
type IChartType,
type IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type InitialState = IChartInput & {
startDate: string | null;
endDate: string | null;
}
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
name: "screen_view",
chartType: "linear",
interval: "day",
name: 'screen_view',
chartType: 'linear',
interval: 'day',
breakdowns: [],
events: [],
range: 30,
@@ -27,21 +27,21 @@ const initialState: InitialState = {
};
export const reportSlice = createSlice({
name: "counter",
name: 'counter',
initialState,
reducers: {
reset() {
return initialState
return initialState;
},
setReport(state, action: PayloadAction<IChartInput>) {
setReport(state, action: PayloadAction<IChartInput>) {
return {
...action.payload,
startDate: null,
endDate: null,
}
};
},
// Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.events.push({
id: alphabetIds[state.events.length]!,
...action.payload,
@@ -51,10 +51,10 @@ export const reportSlice = createSlice({
state,
action: PayloadAction<{
id: string;
}>,
}>
) => {
state.events = state.events.filter(
(event) => event.id !== action.payload.id,
(event) => event.id !== action.payload.id
);
},
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
@@ -69,7 +69,7 @@ export const reportSlice = createSlice({
// Breakdowns
addBreakdown: (
state,
action: PayloadAction<Omit<IChartBreakdown, "id">>,
action: PayloadAction<Omit<IChartBreakdown, 'id'>>
) => {
state.breakdowns.push({
id: alphabetIds[state.breakdowns.length]!,
@@ -80,10 +80,10 @@ export const reportSlice = createSlice({
state,
action: PayloadAction<{
id: string;
}>,
}>
) => {
state.breakdowns = state.breakdowns.filter(
(event) => event.id !== action.payload.id,
(event) => event.id !== action.payload.id
);
},
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
@@ -99,7 +99,7 @@ export const reportSlice = createSlice({
changeInterval: (state, action: PayloadAction<IInterval>) => {
state.interval = action.payload;
},
// Chart type
changeChartType: (state, action: PayloadAction<IChartType>) => {
state.chartType = action.payload;
@@ -116,15 +116,15 @@ export const reportSlice = createSlice({
},
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.range = action.payload
state.range = action.payload;
if (action.payload === 0.3 || action.payload === 0.6) {
state.interval = "minute";
state.interval = 'minute';
} else if (action.payload === 0 || action.payload === 1) {
state.interval = "hour";
state.interval = 'hour';
} else if (action.payload <= 30) {
state.interval = "day";
state.interval = 'day';
} else {
state.interval = "month";
state.interval = 'month';
}
},
},

View File

@@ -1,104 +1,100 @@
// Inspired by react-hot-toast library
import * as React from "react"
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[]
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case "UPDATE_TOAST":
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
};
case "DISMISS_TOAST": {
const { toastId } = action
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
addToRemoveQueue(toast.id);
});
}
return {
@@ -111,82 +107,82 @@ export const reducer = (state: State, action: Action): State => {
}
: t
),
}
};
}
case "REMOVE_TOAST":
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
};
}
}
};
const listeners: Array<(state: State) => void> = []
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] }
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState)
})
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: "ADD_TOAST",
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
if (!open) dismiss();
},
},
})
});
return {
id: id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast }
export { useToast, toast };

View File

@@ -1,5 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
/**
@@ -11,14 +11,14 @@ export const env = createEnv({
.string()
.url()
.refine(
(str) => !str.includes("YOUR_MYSQL_URL_HERE"),
"You forgot to change the default URL"
(str) => !str.includes('YOUR_MYSQL_URL_HERE'),
'You forgot to change the default URL'
),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
.enum(['development', 'test', 'production'])
.default('development'),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
process.env.NODE_ENV === 'production'
? z.string()
: z.string().optional(),
NEXTAUTH_URL: z.preprocess(

View File

@@ -1,27 +1,32 @@
import { type IInterval } from "@/types";
import { type IInterval } from '@/types';
export function formatDateInterval(interval: IInterval, date: Date): string {
if (interval === "hour" || interval === "minute") {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
if (interval === 'hour' || interval === 'minute') {
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
if (interval === "month") {
return new Intl.DateTimeFormat("en-GB", { month: "short" }).format(date);
if (interval === 'month') {
return new Intl.DateTimeFormat('en-GB', { month: 'short' }).format(date);
}
if (interval === "day") {
return new Intl.DateTimeFormat("en-GB", { weekday: "short", day: '2-digit', month: '2-digit' }).format(
date,
);
if (interval === 'day') {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(date);
}
return date.toISOString();
}
export function useFormatDateInterval(interval: IInterval) {
return (date: Date | string) => formatDateInterval(interval, typeof date === "string" ? new Date(date) : date);
}
return (date: Date | string) =>
formatDateInterval(
interval,
typeof date === 'string' ? new Date(date) : date
);
}

View File

@@ -1,7 +1,7 @@
import mappings from '@/mappings.json'
import mappings from '@/mappings.json';
export function useMappings() {
return (val: string) => {
return mappings.find((item) => item.id === val)?.name ?? val
}
}
return mappings.find((item) => item.id === val)?.name ?? val;
};
}

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { useQueryParams } from "./useQueryParams";
import { z } from 'zod';
import { useQueryParams } from './useQueryParams';
export function useOrganizationParams() {
return useQueryParams(
@@ -8,6 +9,6 @@ export function useOrganizationParams() {
project: z.string(),
dashboard: z.string(),
profileId: z.string().optional(),
}),
})
);
}
}

View File

@@ -1,7 +1,6 @@
import { useMemo } from "react";
import { useRouter } from "next/router";
import { type z } from "zod";
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import { type z } from 'zod';
export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) {
const router = useRouter();

View File

@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from '@tanstack/react-query';
export function useRefetchActive() {
const client = useQueryClient()
return () => client.refetchQueries({type: 'active'})
}
const client = useQueryClient();
return () => client.refetchQueries({ type: 'active' });
}

View File

@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
export function useRouterBeforeLeave(callback: () => void) {
const router = useRouter();
@@ -8,14 +8,14 @@ export function useRouterBeforeLeave(callback: () => void) {
useEffect(() => {
const handleRouteChange = (url: string) => {
if (prevUrl.current !== url) {
callback()
callback();
}
prevUrl.current = url;
};
router.events.on("routeChangeStart", handleRouteChange);
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
router.events.off('routeChangeStart', handleRouteChange);
};
}, [router, callback]);
}
}

View File

@@ -1,146 +1,146 @@
[
{
"id": "clbow140n0228u99ui1t24l85",
"name": "Mjölkproteinfritt"
},
{
"id": "cl04a3trn0015ix50tzy40pyf",
"name": "Måltider"
},
{
"id": "cl04a5p7s0401ix505n75yfcn",
"name": "Svårighetsgrad"
},
{
"id": "cl04a7k3i0813ix50aau6yxqg",
"name": "Tid"
},
{
"id": "cl04a47fu0103ix50dqckz2vc",
"name": "Frukost"
},
{
"id": "cl04a4hvu0152ix50o0w4iy8l",
"name": "Mellis"
},
{
"id": "cl04a58ju0281ix50kdmcwst6",
"name": "Dessert"
},
{
"id": "cl04a5fjc0321ix50xiwhuydy",
"name": "Smakportioner"
},
{
"id": "cl04a5kcu0361ix50bnmbhoxz",
"name": "Plockmat"
},
{
"id": "cl04a60sk0496ix50et419drf",
"name": "Medium"
},
{
"id": "cl04a67lx0536ix50sstoxnhi",
"name": "Avancerat"
},
{
"id": "cl04a7qi60850ix50je7vaxo3",
"name": "0-10 min"
},
{
"id": "cl04a7vxi0890ix50veumcuyu",
"name": "10-20 min"
},
{
"id": "cl04a82bj0930ix50bboh3tl9",
"name": "20-30 min"
},
{
"id": "cl04a8a7a0970ix50uet02cqh",
"name": "30-40 min"
},
{
"id": "cl04a8g151010ix50z4cnf2kg",
"name": "40-50 min"
},
{
"id": "cl04a8mqy1050ix50z0d1ho1a",
"name": "50-60 min"
},
{
"id": "cl04a5ujg0447ix50vd3vor87",
"name": "Lätt"
},
{
"id": "cl04a4qv60201ix50b8q5kn9r",
"name": "Lunch & Middag"
},
{
"id": "clak50jem0072ri9ugwygg5ko",
"name": "Annat"
},
{
"id": "clak510qm0120ri9upqkca39s",
"name": "För hela familjen"
},
{
"id": "clak59l8x0085yd9uzllcuci5",
"name": "Under 3 ingredienser"
},
{
"id": "clak59l8y0087yd9u53qperp8",
"name": "Under 5 ingredienser"
},
{
"id": "claslo2sg0404no9uo2tckm5i",
"name": "Huvudingredienser"
},
{
"id": "claslv9s20491no9ugo4fd9ns",
"name": "Fisk"
},
{
"id": "claslv9s30493no9umug5po29",
"name": "Kyckling"
},
{
"id": "claslv9s40495no9umor61pql",
"name": "Kött"
},
{
"id": "claslv9s40497no9uttwkt47n",
"name": "Korv"
},
{
"id": "claslv9s50499no9uch0lhs9i",
"name": "Vegetariskt"
},
{
"id": "clb1y44f40128np9uufck0iqf",
"name": "Årstider"
},
{
"id": "clb1y4ks80202np9uh43c84ts",
"name": "Jul"
},
{
"id": "clbovy0fd0081u99u8dr0yplr",
"name": "Allergier"
},
{
"id": "clbow140p0230u99uk9e7g1u1",
"name": "Äggfritt"
},
{
"id": "clbow140q0232u99uy3lwukvc",
"name": "Vetefritt"
},
{
"id": "clbow140q0234u99uiyrujxd4",
"name": "Glutenfritt"
},
{
"id": "clbow140r0236u99u5333gpei",
"name": "Nötfritt"
}
]
{
"id": "clbow140n0228u99ui1t24l85",
"name": "Mjölkproteinfritt"
},
{
"id": "cl04a3trn0015ix50tzy40pyf",
"name": "Måltider"
},
{
"id": "cl04a5p7s0401ix505n75yfcn",
"name": "Svårighetsgrad"
},
{
"id": "cl04a7k3i0813ix50aau6yxqg",
"name": "Tid"
},
{
"id": "cl04a47fu0103ix50dqckz2vc",
"name": "Frukost"
},
{
"id": "cl04a4hvu0152ix50o0w4iy8l",
"name": "Mellis"
},
{
"id": "cl04a58ju0281ix50kdmcwst6",
"name": "Dessert"
},
{
"id": "cl04a5fjc0321ix50xiwhuydy",
"name": "Smakportioner"
},
{
"id": "cl04a5kcu0361ix50bnmbhoxz",
"name": "Plockmat"
},
{
"id": "cl04a60sk0496ix50et419drf",
"name": "Medium"
},
{
"id": "cl04a67lx0536ix50sstoxnhi",
"name": "Avancerat"
},
{
"id": "cl04a7qi60850ix50je7vaxo3",
"name": "0-10 min"
},
{
"id": "cl04a7vxi0890ix50veumcuyu",
"name": "10-20 min"
},
{
"id": "cl04a82bj0930ix50bboh3tl9",
"name": "20-30 min"
},
{
"id": "cl04a8a7a0970ix50uet02cqh",
"name": "30-40 min"
},
{
"id": "cl04a8g151010ix50z4cnf2kg",
"name": "40-50 min"
},
{
"id": "cl04a8mqy1050ix50z0d1ho1a",
"name": "50-60 min"
},
{
"id": "cl04a5ujg0447ix50vd3vor87",
"name": "Lätt"
},
{
"id": "cl04a4qv60201ix50b8q5kn9r",
"name": "Lunch & Middag"
},
{
"id": "clak50jem0072ri9ugwygg5ko",
"name": "Annat"
},
{
"id": "clak510qm0120ri9upqkca39s",
"name": "För hela familjen"
},
{
"id": "clak59l8x0085yd9uzllcuci5",
"name": "Under 3 ingredienser"
},
{
"id": "clak59l8y0087yd9u53qperp8",
"name": "Under 5 ingredienser"
},
{
"id": "claslo2sg0404no9uo2tckm5i",
"name": "Huvudingredienser"
},
{
"id": "claslv9s20491no9ugo4fd9ns",
"name": "Fisk"
},
{
"id": "claslv9s30493no9umug5po29",
"name": "Kyckling"
},
{
"id": "claslv9s40495no9umor61pql",
"name": "Kött"
},
{
"id": "claslv9s40497no9uttwkt47n",
"name": "Korv"
},
{
"id": "claslv9s50499no9uch0lhs9i",
"name": "Vegetariskt"
},
{
"id": "clb1y44f40128np9uufck0iqf",
"name": "Årstider"
},
{
"id": "clb1y4ks80202np9uh43c84ts",
"name": "Jul"
},
{
"id": "clbovy0fd0081u99u8dr0yplr",
"name": "Allergier"
},
{
"id": "clbow140p0230u99uk9e7g1u1",
"name": "Äggfritt"
},
{
"id": "clbow140q0232u99uy3lwukvc",
"name": "Vetefritt"
},
{
"id": "clbow140q0234u99uiyrujxd4",
"name": "Glutenfritt"
},
{
"id": "clbow140r0236u99u5333gpei",
"name": "Nötfritt"
}
]

View File

@@ -1,26 +1,22 @@
import { type Session } from "next-auth";
import { SessionProvider, getSession } from "next-auth/react";
import App, {
type AppContext,
type AppInitialProps,
type AppType,
} from "next/app";
import store from "@/redux";
import { Provider as ReduxProvider } from "react-redux";
import { Suspense } from "react";
import { Space_Grotesk } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { Suspense } from 'react';
import { Toaster } from '@/components/ui/toaster';
import store from '@/redux';
import { api } from '@/utils/api';
import { type Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import { type AppType } from 'next/app';
import { Space_Grotesk } from 'next/font/google';
import { Provider as ReduxProvider } from 'react-redux';
import { api } from "@/utils/api";
import '@/styles/globals.css';
import "@/styles/globals.css";
import { ModalProvider } from "@/modals";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TooltipProvider } from '@/components/ui/tooltip';
import { ModalProvider } from '@/modals';
const font = Space_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--text",
subsets: ['latin'],
display: 'swap',
variable: '--text',
});
const MixanApp: AppType<{ session: Session | null }> = ({

View File

@@ -1,5 +1,4 @@
import NextAuth from "next-auth";
import { authOptions } from "@/server/auth";
import { authOptions } from '@/server/auth';
import NextAuth from 'next-auth';
export default NextAuth(authOptions);

View File

@@ -1,24 +1,22 @@
import { validateSdkRequest } from '@/server/auth'
import { db } from '@/server/db'
import { createError, handleError } from '@/server/exceptions'
import { type EventPayload } from '@mixan/types'
import type { NextApiRequest, NextApiResponse } from 'next'
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import { type EventPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: Array<EventPayload>
body: Array<EventPayload>;
}
export default async function handler(
req: Request,
res: NextApiResponse
) {
if(req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'))
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req)
const projectId = await validateSdkRequest(req);
await db.event.createMany({
data: req.body.map((event) => ({
@@ -27,11 +25,11 @@ export default async function handler(
createdAt: event.time,
project_id: projectId,
profile_id: event.profileId,
}))
})
res.status(200).end()
})),
});
res.status(200).end();
} catch (error) {
handleError(res, error)
handleError(res, error);
}
}
}

View File

@@ -1,16 +1,17 @@
import { validateSdkRequest } from "@/server/auth";
import { createError, handleError } from "@/server/exceptions";
import { tickProfileProperty } from "@/server/services/profile.service";
import { type ProfileIncrementPayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import { type ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT") {
return handleError(res, createError(405, "Method not allowed"));
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {

View File

@@ -1,21 +1,22 @@
import { validateSdkRequest } from "@/server/auth";
import { createError, handleError } from "@/server/exceptions";
import { tickProfileProperty } from "@/server/services/profile.service";
import { type ProfileIncrementPayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import { type ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT") {
return handleError(res, createError(405, "Method not allowed"));
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req)
await validateSdkRequest(req);
const profileId = req.query.profileId as string;

View File

@@ -1,26 +1,27 @@
import { validateSdkRequest } from "@/server/auth";
import { db } from "@/server/db";
import { createError, handleError } from "@/server/exceptions";
import { getProfile } from "@/server/services/profile.service";
import { type ProfilePayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import { getProfile } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import { type ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT" && req.method !== "POST") {
return handleError(res, createError(405, "Method not allowed"));
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== 'PUT' && req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req)
await validateSdkRequest(req);
const profileId = req.query.profileId as string;
const profile = await getProfile(profileId)
const profile = await getProfile(profileId);
const { body } = req;
await db.profile.update({
where: {
@@ -33,7 +34,7 @@ export default async function handler(req: Request, res: NextApiResponse) {
last_name: body.last_name,
avatar: body.avatar,
properties: {
...(typeof profile.properties === "object"
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(body.properties ?? {}),

View File

@@ -1,8 +1,8 @@
import { validateSdkRequest } from "@/server/auth";
import { db } from "@/server/db";
import { createError, handleError } from "@/server/exceptions";
import type { NextApiRequest, NextApiResponse } from "next";
import randomAnimalName from "random-animal-name";
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import randomAnimalName from 'random-animal-name';
interface Request extends NextApiRequest {
body: {
@@ -12,8 +12,8 @@ interface Request extends NextApiRequest {
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "POST") {
return handleError(res, createError(405, "Method not allowed"));
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {

View File

@@ -1,10 +1,13 @@
import { db } from "@/server/db";
import { handleError } from "@/server/exceptions";
import { hashPassword } from "@/server/services/hash.service";
import { randomUUID } from "crypto";
import { type NextApiRequest, type NextApiResponse } from "next";
import { randomUUID } from 'crypto';
import { db } from '@/server/db';
import { handleError } from '@/server/exceptions';
import { hashPassword } from '@/server/services/hash.service';
import { type NextApiRequest, type NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const counts = await db.$transaction([
db.organization.count(),
@@ -13,25 +16,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
]);
if (counts.some((count) => count > 0)) {
return res.json("Setup already done");
return res.json('Setup already done');
}
const organization = await db.organization.create({
data: {
name: "Acme Inc.",
name: 'Acme Inc.',
},
});
const project = await db.project.create({
data: {
name: "Acme Website",
name: 'Acme Website',
organization_id: organization.id,
},
});
const secret = randomUUID();
const client = await db.client.create({
data: {
name: "Acme Website Client",
name: 'Acme Website Client',
project_id: project.id,
organization_id: organization.id,
secret: await hashPassword(secret),

View File

@@ -1,18 +1,17 @@
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { env } from "@/env.mjs";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { env } from '@/env.mjs';
import { appRouter } from '@/server/api/root';
import { createTRPCContext } from '@/server/api/trpc';
import { createNextApiHandler } from '@trpc/server/adapters/next';
// export API handler
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
env.NODE_ENV === "development"
env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`
);
}
: undefined,

View File

@@ -1,21 +1,21 @@
import { MainLayout } from "@/components/layouts/MainLayout";
import { api } from "@/utils/api";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { createServerSideProps } from "@/server/getServerSideProps";
import { useEffect } from 'react';
import { MainLayout } from '@/components/layouts/MainLayout';
import { createServerSideProps } from '@/server/getServerSideProps';
import { api } from '@/utils/api';
import { useRouter } from 'next/router';
export const getServerSideProps = createServerSideProps()
export const getServerSideProps = createServerSideProps();
export default function Home() {
const router = useRouter()
const router = useRouter();
const query = api.organization.first.useQuery();
const organization = query.data ?? null;
useEffect(() => {
if(organization) {
router.replace(`/${organization.slug}`)
if (organization) {
router.replace(`/${organization.slug}`);
}
}, [organization])
}, [organization, router]);
return (
<MainLayout>

View File

@@ -1,18 +1,21 @@
import reportSlice from '@/components/report/reportSlice'
import { configureStore } from '@reduxjs/toolkit'
import { type TypedUseSelectorHook, useDispatch as useBaseDispatch, useSelector as useBaseSelector } from 'react-redux'
import reportSlice from '@/components/report/reportSlice';
import { configureStore } from '@reduxjs/toolkit';
import {
useDispatch as useBaseDispatch,
useSelector as useBaseSelector,
type TypedUseSelectorHook,
} from 'react-redux';
const store = configureStore({
reducer: {
report: reportSlice
report: reportSlice,
},
})
});
export type RootState = ReturnType<typeof store.getState>
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch
export const useDispatch: () => AppDispatch = useBaseDispatch
export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector
export type AppDispatch = typeof store.dispatch;
export const useDispatch: () => AppDispatch = useBaseDispatch;
export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector;
export default store
export default store;

View File

@@ -1,13 +1,14 @@
import { createTRPCRouter } from "@/server/api/trpc";
import { chartRouter } from "./routers/chart";
import { reportRouter } from "./routers/report";
import { organizationRouter } from "./routers/organization";
import { userRouter } from "./routers/user";
import { projectRouter } from "./routers/project";
import { clientRouter } from "./routers/client";
import { dashboardRouter } from "./routers/dashboard";
import { eventRouter } from "./routers/event";
import { profileRouter } from "./routers/profile";
import { createTRPCRouter } from '@/server/api/trpc';
import { chartRouter } from './routers/chart';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event';
import { organizationRouter } from './routers/organization';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
import { reportRouter } from './routers/report';
import { userRouter } from './routers/user';
/**
* This is the primary router for your server.

View File

@@ -1,15 +1,15 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { last, pipe, sort, uniq } from "ramda";
import { toDots } from "@/utils/object";
import { zChartInputWithDates } from "@/utils/validation";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import {
type IChartInputWithDates,
type IChartEvent,
type IChartInputWithDates,
type IChartRange,
} from "@/types";
import { getDaysOldDate } from "@/utils/date";
} from '@/types';
import { getDaysOldDate } from '@/utils/date';
import { toDots } from '@/utils/object';
import { zChartInputWithDates } from '@/utils/validation';
import { last, pipe, sort, uniq } from 'ramda';
import { z } from 'zod';
export const config = {
api: {
@@ -21,7 +21,7 @@ export const chartRouter = createTRPCRouter({
events: protectedProcedure.query(async () => {
const events = await db.event.findMany({
take: 500,
distinct: ["name"],
distinct: ['name'],
});
return events;
@@ -47,12 +47,12 @@ export const chartRouter = createTRPCRouter({
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[])
.map((item) => item.replace(/\.([0-9]+)\./g, ".*."))
.map((item) => item.replace(/\.([0-9]+)/g, "[*]"));
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'));
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq,
uniq
)(properties);
}),
@@ -62,10 +62,10 @@ export const chartRouter = createTRPCRouter({
if (isJsonPath(input.property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath(
input.property,
input.property
)} AS value from events WHERE name = '${
input.event
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`,
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
);
return {
values: uniq(events.map((item) => item.value)),
@@ -102,12 +102,12 @@ export const chartRouter = createTRPCRouter({
...(await getChartData({
...input,
event,
})),
}))
);
}
const sorted = [...series].sort((a, b) => {
if (input.chartType === "linear") {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA;
@@ -132,8 +132,8 @@ export const chartRouter = createTRPCRouter({
}
return acc;
},
{} as Record<(typeof series)[number]["event"]["id"], number>,
),
{} as Record<(typeof series)[number]['event']['id'], number>
)
).map(([id, count]) => ({
count,
...events.find((event) => event.id === id)!,
@@ -148,13 +148,13 @@ export const chartRouter = createTRPCRouter({
function selectJsonPath(property: string) {
const jsonPath = property
.replace(/^properties\./, "")
.replace(/\.\*\./g, ".**.");
.replace(/^properties\./, '')
.replace(/\.\*\./g, '.**.');
return `jsonb_path_query(properties, '$.${jsonPath}')`;
}
function isJsonPath(property: string) {
return property.startsWith("properties");
return property.startsWith('properties');
}
type ResultItem = {
@@ -164,12 +164,12 @@ type ResultItem = {
};
function propertyNameToSql(name: string) {
if (name.includes(".")) {
if (name.includes('.')) {
const str = name
.split(".")
.split('.')
.map((item, index) => (index === 0 ? item : `'${item}'`))
.join("->");
const findLastOf = "->";
.join('->');
const findLastOf = '->';
const lastArrow = str.lastIndexOf(findLastOf);
if (lastArrow === -1) {
return str;
@@ -224,27 +224,34 @@ function getDatesFromRange(range: IChartRange) {
};
}
function getChartSql({ event, chartType, breakdowns, interval, startDate, endDate }: Omit<IGetChartDataInput, 'range'>) {
function getChartSql({
event,
chartType,
breakdowns,
interval,
startDate,
endDate,
}: Omit<IGetChartDataInput, 'range'>) {
const select = [];
const where = [];
const groupBy = [];
const orderBy = [];
if (event.segment === "event") {
if (event.segment === 'event') {
select.push(`count(*)::int as count`);
} else {
select.push(`count(DISTINCT profile_id)::int as count`);
}
switch (chartType) {
case "bar": {
orderBy.push("count DESC");
case 'bar': {
orderBy.push('count DESC');
break;
}
case "linear": {
case 'linear': {
select.push(`date_trunc('${interval}', "createdAt") as date`);
groupBy.push("date");
orderBy.push("date");
groupBy.push('date');
orderBy.push('date');
break;
}
}
@@ -256,38 +263,38 @@ function getChartSql({ event, chartType, breakdowns, interval, startDate, endDat
filters.forEach((filter) => {
const { name, value } = filter;
switch (filter.operator) {
case "is": {
if (name.includes(".*.") || name.endsWith("[*]")) {
case 'is': {
if (name.includes('.*.') || name.endsWith('[*]')) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, "")
.replace(/\.\*\./g, "[*].")} ? (${value
.replace(/^properties\./, '')
.replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ == "${val}"`)
.join(" || ")})'`,
.join(' || ')})'`
);
} else {
where.push(
`${propertyNameToSql(name)} in (${value
.map((val) => `'${val}'`)
.join(", ")})`,
.join(', ')})`
);
}
break;
}
case "isNot": {
if (name.includes(".*.") || name.endsWith("[*]")) {
case 'isNot': {
if (name.includes('.*.') || name.endsWith('[*]')) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, "")
.replace(/\.\*\./g, "[*].")} ? (${value
.replace(/^properties\./, '')
.replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ != "${val}"`)
.join(" && ")})'`,
.join(' && ')})'`
);
} else if (name.includes(".")) {
} else if (name.includes('.')) {
where.push(
`${propertyNameToSql(name)} not in (${value
.map((val) => `'${val}'`)
.join(", ")})`,
.join(', ')})`
);
}
break;
@@ -322,24 +329,24 @@ function getChartSql({ event, chartType, breakdowns, interval, startDate, endDat
}
const sql = [
`SELECT ${select.join(", ")}`,
`SELECT ${select.join(', ')}`,
`FROM events`,
`WHERE ${where.join(" AND ")}`,
`WHERE ${where.join(' AND ')}`,
];
if (groupBy.length) {
sql.push(`GROUP BY ${groupBy.join(", ")}`);
sql.push(`GROUP BY ${groupBy.join(', ')}`);
}
if (orderBy.length) {
sql.push(`ORDER BY ${orderBy.join(", ")}`);
sql.push(`ORDER BY ${orderBy.join(', ')}`);
}
return sql.join("\n");
return sql.join('\n');
}
type IGetChartDataInput = {
event: IChartEvent;
} & Omit<IChartInputWithDates, "events" | 'name'>
} & Omit<IChartInputWithDates, 'events' | 'name'>;
async function getChartData({
chartType,
@@ -365,23 +372,24 @@ async function getChartData({
interval,
startDate,
endDate,
})
});
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
if(result.length === 0 && breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql({
chartType,
event,
breakdowns: [],
interval,
startDate,
endDate,
}));
if (result.length === 0 && breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(
getChartSql({
chartType,
event,
breakdowns: [],
interval,
startDate,
endDate,
})
);
}
console.log(sql);
// group by sql label
const series = result.reduce(
@@ -401,7 +409,7 @@ async function getChartData({
...acc,
};
},
{} as Record<string, ResultItem[]>,
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
@@ -416,7 +424,7 @@ async function getChartData({
},
totalCount: getTotalCount(data),
data:
chartType === "linear"
chartType === 'linear'
? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map(
(item) => {
return {
@@ -424,7 +432,7 @@ async function getChartData({
count: item.count,
date: new Date(item.date).toISOString(),
};
},
}
)
: [],
};
@@ -435,17 +443,17 @@ function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: string,
startDate: string,
endDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if(interval === 'minute') { 
clonedStartDate.setSeconds(0, 0)
if (interval === 'minute') {
clonedStartDate.setSeconds(0, 0);
clonedEndDate.setMinutes(clonedEndDate.getMinutes() + 1, 0, 0);
} else if (interval === "hour") {
} else if (interval === 'hour') {
clonedStartDate.setMinutes(0, 0, 0);
clonedEndDate.setMinutes(0, 0, 0);
} else {
@@ -455,7 +463,7 @@ function fillEmptySpotsInTimeline(
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === "month" &&
interval === 'month' &&
clonedStartDate.getFullYear() === today.getFullYear() &&
clonedStartDate.getMonth() === today.getMonth();
@@ -472,20 +480,20 @@ function fillEmptySpotsInTimeline(
const item = items.find((item) => {
const date = new Date(item.date);
if (interval === "month") {
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === "day") {
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === "hour") {
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
@@ -493,7 +501,7 @@ function fillEmptySpotsInTimeline(
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === "minute") {
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
@@ -515,19 +523,19 @@ function fillEmptySpotsInTimeline(
}
switch (interval) {
case "day": {
case 'day': {
clonedStartDate.setDate(clonedStartDate.getDate() + 1);
break;
}
case "hour": {
case 'hour': {
clonedStartDate.setHours(clonedStartDate.getHours() + 1);
break;
}
case "minute": {
case 'minute': {
clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1);
break;
}
case "month": {
case 'month': {
clonedStartDate.setMonth(clonedStartDate.getMonth() + 1);
break;
}

View File

@@ -1,17 +1,16 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { hashPassword } from "@/server/services/hash.service";
import { randomUUID } from "crypto";
import { getOrganizationBySlug } from "@/server/services/organization.service";
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { hashPassword } from '@/server/services/hash.service';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { z } from 'zod';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string(),
}),
})
)
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -28,7 +27,7 @@ export const clientRouter = createTRPCRouter({
.input(
z.object({
id: z.string(),
}),
})
)
.query(({ input }) => {
return db.client.findUniqueOrThrow({
@@ -42,7 +41,7 @@ export const clientRouter = createTRPCRouter({
z.object({
id: z.string(),
name: z.string(),
}),
})
)
.mutation(({ input }) => {
return db.client.update({
@@ -60,7 +59,7 @@ export const clientRouter = createTRPCRouter({
name: z.string(),
projectId: z.string(),
organizationSlug: z.string(),
}),
})
)
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -83,7 +82,7 @@ export const clientRouter = createTRPCRouter({
.input(
z.object({
id: z.string(),
}),
})
)
.mutation(async ({ input }) => {
await db.client.delete({

View File

@@ -1,9 +1,8 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getProjectBySlug } from "@/server/services/project.service";
import { slug } from "@/utils/slug";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getProjectBySlug } from '@/server/services/project.service';
import { slug } from '@/utils/slug';
import { z } from 'zod';
export const dashboardRouter = createTRPCRouter({
list: protectedProcedure
@@ -15,12 +14,12 @@ export const dashboardRouter = createTRPCRouter({
.or(
z.object({
projectId: z.string(),
}),
),
})
)
)
.query(async ({ input }) => {
let projectId = null;
if ("projectId" in input) {
if ('projectId' in input) {
projectId = input.projectId;
} else {
projectId = (await getProjectBySlug(input.projectSlug)).id;
@@ -37,7 +36,7 @@ export const dashboardRouter = createTRPCRouter({
z.object({
name: z.string(),
projectId: z.string(),
}),
})
)
.mutation(async ({ input: { projectId, name } }) => {
return db.dashboard.create({

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
export const config = {
api: {
@@ -16,7 +16,7 @@ export const eventRouter = createTRPCRouter({
take: z.number().default(100),
skip: z.number().default(0),
profileId: z.string().optional(),
}),
})
)
.query(async ({ input: { take, skip, projectSlug, profileId } }) => {
const project = await db.project.findUniqueOrThrow({
@@ -29,10 +29,10 @@ export const eventRouter = createTRPCRouter({
skip,
where: {
project_id: project.id,
profile_id: profileId
profile_id: profileId,
},
orderBy: {
createdAt: "desc",
createdAt: 'desc',
},
include: {
profile: true,

View File

@@ -1,9 +1,8 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getOrganizationBySlug } from "@/server/services/organization.service";
import { slug } from "@/utils/slug";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { slug } from '@/utils/slug';
import { z } from 'zod';
export const organizationRouter = createTRPCRouter({
first: protectedProcedure.query(({ ctx }) => {
@@ -21,17 +20,17 @@ export const organizationRouter = createTRPCRouter({
.input(
z.object({
slug: z.string(),
}),
})
)
.query(({ input }) => {
return getOrganizationBySlug(input.slug)
return getOrganizationBySlug(input.slug);
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
}),
})
)
.mutation(({ input }) => {
return db.organization.update({
@@ -40,7 +39,7 @@ export const organizationRouter = createTRPCRouter({
},
data: {
name: input.name,
slug: slug(input.name)
slug: slug(input.name),
},
});
}),

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
export const config = {
api: {
@@ -15,7 +15,7 @@ export const profileRouter = createTRPCRouter({
projectSlug: z.string(),
take: z.number().default(100),
skip: z.number().default(0),
}),
})
)
.query(async ({ input: { take, skip, projectSlug } }) => {
const project = await db.project.findUniqueOrThrow({
@@ -30,7 +30,7 @@ export const profileRouter = createTRPCRouter({
project_id: project.id,
},
orderBy: {
createdAt: "desc",
createdAt: 'desc',
},
});
}),

View File

@@ -1,15 +1,14 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getOrganizationBySlug } from "@/server/services/organization.service";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { z } from 'zod';
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string(),
}),
})
)
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -23,7 +22,7 @@ export const projectRouter = createTRPCRouter({
.input(
z.object({
id: z.string(),
}),
})
)
.query(({ input }) => {
return db.project.findUniqueOrThrow({
@@ -37,7 +36,7 @@ export const projectRouter = createTRPCRouter({
z.object({
id: z.string(),
name: z.string(),
}),
})
)
.mutation(({ input }) => {
return db.project.update({
@@ -54,7 +53,7 @@ export const projectRouter = createTRPCRouter({
z.object({
name: z.string(),
organizationSlug: z.string(),
}),
})
)
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -69,7 +68,7 @@ export const projectRouter = createTRPCRouter({
.input(
z.object({
id: z.string(),
}),
})
)
.mutation(async ({ input }) => {
await db.project.delete({

View File

@@ -1,48 +1,54 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { zChartInput } from "@/utils/validation";
import { db } from "@/server/db";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getDashboardBySlug } from '@/server/services/dashboard.service';
import { getProjectBySlug } from '@/server/services/project.service';
import {
type IChartInput,
type IChartBreakdown,
type IChartEvent,
type IChartEventFilter,
type IChartInput,
type IChartRange,
} from "@/types";
import { type Report as DbReport } from "@prisma/client";
import { getProjectBySlug } from "@/server/services/project.service";
import { getDashboardBySlug } from "@/server/services/dashboard.service";
import { alphabetIds } from "@/utils/constants";
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { zChartInput } from '@/utils/validation';
import { type Report as DbReport } from '@prisma/client';
import { z } from 'zod';
function transformFilter(filter: Partial<IChartEventFilter>, index: number): IChartEventFilter {
function transformFilter(
filter: Partial<IChartEventFilter>,
index: number
): IChartEventFilter {
return {
id: filter.id ?? alphabetIds[index]!,
name: filter.name ?? 'Unknown Filter',
operator: filter.operator ?? 'is',
value: typeof filter.value === 'string' ? [filter.value] : filter.value ?? [],
}
value:
typeof filter.value === 'string' ? [filter.value] : filter.value ?? [],
};
}
function transformEvent(event: Partial<IChartEvent>, index: number): IChartEvent {
function transformEvent(
event: Partial<IChartEvent>,
index: number
): IChartEvent {
return {
segment: event.segment ?? 'event',
filters: (event.filters ?? []).map(transformFilter),
id: event.id ?? alphabetIds[index]!,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: event.name || 'Untitled',
}
};
}
function transformReport(report: DbReport): IChartInput & { id: string } {
return {
id: report.id,
events: (report.events as IChartEvent[]).map(transformEvent),
events: (report.events as IChartEvent[]).map(transformEvent),
breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chart_type,
interval: report.interval,
name: report.name || 'Untitled',
range: report.range as IChartRange ?? 30,
range: (report.range as IChartRange) ?? 30,
};
}
@@ -51,7 +57,7 @@ export const reportRouter = createTRPCRouter({
.input(
z.object({
id: z.string(),
}),
})
)
.query(({ input: { id } }) => {
return db.report
@@ -67,7 +73,7 @@ export const reportRouter = createTRPCRouter({
z.object({
projectSlug: z.string(),
dashboardSlug: z.string(),
}),
})
)
.query(async ({ input: { projectSlug, dashboardSlug } }) => {
const project = await getProjectBySlug(projectSlug);
@@ -82,7 +88,7 @@ export const reportRouter = createTRPCRouter({
return {
reports: reports.map(transformReport),
dashboard,
}
};
}),
save: protectedProcedure
.input(
@@ -90,7 +96,7 @@ export const reportRouter = createTRPCRouter({
report: zChartInput,
projectId: z.string(),
dashboardId: z.string(),
}),
})
)
.mutation(({ input: { report, projectId, dashboardId } }) => {
return db.report.create({
@@ -105,7 +111,6 @@ export const reportRouter = createTRPCRouter({
range: report.range,
},
});
}),
update: protectedProcedure
.input(
@@ -114,7 +119,7 @@ export const reportRouter = createTRPCRouter({
report: zChartInput,
projectId: z.string(),
dashboardId: z.string(),
}),
})
)
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
return db.report.update({

View File

@@ -1,63 +1,59 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/services/hash.service";
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { hashPassword, verifyPassword } from '@/server/services/hash.service';
import { z } from 'zod';
export const userRouter = createTRPCRouter({
current: protectedProcedure.query(({ ctx }) => {
return db.user.findUniqueOrThrow({
where: {
id: ctx.session.user.id
}
})
id: ctx.session.user.id,
},
});
}),
update: protectedProcedure
.input(
z.object({
name: z.string(),
email: z.string(),
}),
})
)
.mutation(({ input, ctx }) => {
return db.user.update({
where: {
id: ctx.session.user.id
id: ctx.session.user.id,
},
data: {
name: input.name,
email: input.email,
}
})
},
});
}),
changePassword: protectedProcedure
.input(
z.object({
password: z.string(),
oldPassword: z.string(),
}),
})
)
.mutation(async ({ input, ctx }) => {
const user = await db.user.findUniqueOrThrow({
where: {
id: ctx.session.user.id
}
})
id: ctx.session.user.id,
},
});
if(!(await verifyPassword(input.oldPassword, user.password))) {
throw new Error('Old password is incorrect')
if (!(await verifyPassword(input.oldPassword, user.password))) {
throw new Error('Old password is incorrect');
}
return db.user.update({
where: {
id: ctx.session.user.id
id: ctx.session.user.id,
},
data: {
password: await hashPassword(input.password),
}
})
},
});
}),
});

View File

@@ -7,14 +7,13 @@
* need to use are documented accordingly near the end.
*/
import { initTRPC, TRPCError } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import { type Session } from "next-auth";
import superjson from "superjson";
import { ZodError } from "zod";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { getServerAuthSession } from '@/server/auth';
import { db } from '@/server/db';
import { initTRPC, TRPCError } from '@trpc/server';
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { type Session } from 'next-auth';
import superjson from 'superjson';
import { ZodError } from 'zod';
/**
* 1. CONTEXT
@@ -109,7 +108,7 @@ export const publicProcedure = t.procedure;
/** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {

View File

@@ -1,14 +1,14 @@
import { type NextApiRequest, type GetServerSidePropsContext } from "next";
import { db } from '@/server/db';
import { verifyPassword } from '@/server/services/hash.service';
import { type GetServerSidePropsContext, type NextApiRequest } from 'next';
import {
getServerSession,
type DefaultSession,
type NextAuthOptions,
} from "next-auth";
} from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { db } from "@/server/db";
import Credentials from "next-auth/providers/credentials";
import { createError } from "./exceptions";
import { hashPassword, verifyPassword } from "@/server/services/hash.service";
import { createError } from './exceptions';
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@@ -16,9 +16,9 @@ import { hashPassword, verifyPassword } from "@/server/services/hash.service";
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
declare module 'next-auth' {
interface Session extends DefaultSession {
user: DefaultSession["user"] & {
user: DefaultSession['user'] & {
id: string;
};
}
@@ -45,35 +45,35 @@ export const authOptions: NextAuthOptions = {
}),
},
session: {
strategy: "jwt",
strategy: 'jwt',
},
providers: [
Credentials({
name: "Credentials",
name: 'Credentials',
credentials: {
email: { label: "Email", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" },
email: { label: 'Email', type: 'text', placeholder: 'jsmith' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if(!credentials?.password || !credentials?.email) {
return null
if (!credentials?.password || !credentials?.email) {
return null;
}
const user = await db.user.findFirst({
where: { email: credentials?.email },
});
if(!user) {
return null
if (!user) {
return null;
}
if(!await verifyPassword(credentials.password, user.password)) {
return null
if (!(await verifyPassword(credentials.password, user.password))) {
return null;
}
return {
...user,
image: 'https://api.dicebear.com/7.x/adventurer/svg?seed=Abby'
image: 'https://api.dicebear.com/7.x/adventurer/svg?seed=Abby',
};
},
}),
@@ -95,22 +95,22 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = (ctx: {
req: GetServerSidePropsContext["req"];
res: GetServerSidePropsContext["res"];
req: GetServerSidePropsContext['req'];
res: GetServerSidePropsContext['res'];
}) => {
return getServerSession(ctx.req, ctx.res, authOptions);
};
export async function validateSdkRequest(req: NextApiRequest): Promise<string> {
const clientId = req?.headers["mixan-client-id"] as string | undefined
const clientSecret = req.headers["mixan-client-secret"] as string | undefined
const clientId = req?.headers['mixan-client-id'] as string | undefined;
const clientSecret = req.headers['mixan-client-secret'] as string | undefined;
if (!clientId) {
throw createError(401, "Misisng client id");
throw createError(401, 'Misisng client id');
}
if (!clientSecret) {
throw createError(401, "Misisng client secret");
throw createError(401, 'Misisng client secret');
}
const client = await db.client.findUnique({
@@ -120,14 +120,12 @@ export async function validateSdkRequest(req: NextApiRequest): Promise<string> {
});
if (!client) {
throw createError(401, "Invalid client id");
throw createError(401, 'Invalid client id');
}
if (!(await verifyPassword(clientSecret, client.secret))) {
throw createError(401, "Invalid client secret");
throw createError(401, 'Invalid client secret');
}
return client.project_id
return client.project_id;
}

View File

@@ -1,6 +1,5 @@
import { PrismaClient } from "@prisma/client";
import { env } from "@/env.mjs";
import { env } from '@/env.mjs';
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
@@ -9,8 +8,7 @@ const globalForPrisma = globalThis as unknown as {
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
['error']
log: ['error'],
});
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

View File

@@ -1,55 +1,52 @@
import {
type MixanIssue,
type MixanErrorResponse
} from '@mixan/types'
import { type NextApiResponse } from 'next'
import { type NextApiResponse } from 'next';
import { type MixanErrorResponse, type MixanIssue } from '@mixan/types';
export class HttpError extends Error {
public status: number
public message: string
public issues: MixanIssue[]
public status: number;
public message: string;
public issues: MixanIssue[];
constructor(status: number, message: string | Error, issues?: MixanIssue[]) {
super(message instanceof Error ? message.message : message)
this.status = status
this.message = message instanceof Error ? message.message : message
this.issues = issues ?? []
super(message instanceof Error ? message.message : message);
this.status = status;
this.message = message instanceof Error ? message.message : message;
this.issues = issues ?? [];
}
toJson(): MixanErrorResponse {
toJson(): MixanErrorResponse {
return {
code: this.status,
status: 'error',
message: this.message,
issues: this.issues.length ? this.issues : undefined,
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined,
}
};
}
}
export function createIssues(arr: Array<MixanIssue>) {
throw new HttpError(400, 'Issues', arr)
throw new HttpError(400, 'Issues', arr);
}
export function createError(status = 500, error: unknown) {
if(error instanceof Error || typeof error === 'string') {
return new HttpError(status, error)
}
if (error instanceof Error || typeof error === 'string') {
return new HttpError(status, error);
}
return new HttpError(500, 'Unexpected error occured')
return new HttpError(500, 'Unexpected error occured');
}
export function handleError(res: NextApiResponse, error: unknown) {
if(error instanceof HttpError) {
return res.status(error.status).json(error.toJson())
}
if(error instanceof Error) {
const httpError = createError(500, error)
res.status(httpError.status).json(httpError.toJson())
if (error instanceof HttpError) {
return res.status(error.status).json(error.toJson());
}
const httpError = createError(500, error)
res.status(httpError.status).json(httpError.toJson())
}
if (error instanceof Error) {
const httpError = createError(500, error);
res.status(httpError.status).json(httpError.toJson());
}
const httpError = createError(500, error);
res.status(httpError.status).json(httpError.toJson());
}

View File

@@ -1,45 +1,46 @@
import {
type GetServerSidePropsContext,
type GetServerSidePropsResult,
} from "next";
import { getServerAuthSession } from "./auth";
import { db } from "./db";
} from 'next';
import { getServerAuthSession } from './auth';
import { db } from './db';
export function createServerSideProps(
cb?: (context: GetServerSidePropsContext) => Promise<any>,
cb?: (context: GetServerSidePropsContext) => Promise<any>
) {
return async function getServerSideProps(
context: GetServerSidePropsContext,
context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> {
const session = await getServerAuthSession(context);
const session = await getServerAuthSession(context);
if(!session) {
if (!session) {
return {
redirect: {
destination: "/api/auth/signin",
destination: '/api/auth/signin',
permanent: false,
},
}
};
}
if(context.params?.organization) {
if (context.params?.organization) {
const organization = await db.user.findFirst({
where: {
id: session.user.id,
organization: {
slug: context.params.organization as string
}
}
})
if(!organization) {
slug: context.params.organization as string,
},
},
});
if (!organization) {
return {
notFound: true,
}
};
}
}
const res = await (typeof cb === "function"
const res = await (typeof cb === 'function'
? cb(context)
: Promise.resolve({}));
return {

View File

@@ -1,9 +1,9 @@
import { db } from "../db";
import { db } from '../db';
export function getDashboardBySlug(slug: string) {
return db.dashboard.findUniqueOrThrow({
where: {
slug
slug,
},
});
}
});
}

View File

@@ -1,4 +1,4 @@
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
const keyLength = 32;
/**
@@ -6,17 +6,17 @@ const keyLength = 32;
* @param {string} password
* @returns {string} The salt+hash
*/
export async function hashPassword (password: string): Promise<string> {
export async function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = randomBytes(16).toString("hex");
const salt = randomBytes(16).toString('hex');
scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err);
// derivedKey is of type Buffer
resolve(`${salt}.${derivedKey.toString("hex")}`);
resolve(`${salt}.${derivedKey.toString('hex')}`);
});
});
};
}
/**
* Compare a plain text password with a salt+hash password
@@ -24,11 +24,14 @@ export async function hashPassword (password: string): Promise<string> {
* @param {string} hash The hash+salt to check against
* @returns {boolean}
*/
export async function verifyPassword (password: string, hash: string): Promise<boolean> {
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hashKey] = hash.split(".");
const [salt, hashKey] = hash.split('.');
// we need to pass buffer values to timingSafeEqual
const hashKeyBuff = Buffer.from(hashKey!, "hex");
const hashKeyBuff = Buffer.from(hashKey!, 'hex');
scrypt(password, salt!, keyLength, (err, derivedKey) => {
if (err) {
reject(err);
@@ -37,4 +40,4 @@ export async function verifyPassword (password: string, hash: string): Promise<b
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
});
});
};
}

View File

@@ -1,9 +1,9 @@
import { db } from "../db";
import { db } from '../db';
export function getOrganizationBySlug(slug: string) {
return db.organization.findUniqueOrThrow({
where: {
slug
slug,
},
});
}
});
}

View File

@@ -1,12 +1,12 @@
import { db } from "@/server/db"
import { HttpError } from "@/server/exceptions"
import { db } from '@/server/db';
import { HttpError } from '@/server/exceptions';
export function getProfile(id: string) {
return db.profile.findUniqueOrThrow({
where: {
id,
},
})
});
}
export async function tickProfileProperty({
@@ -14,28 +14,31 @@ export async function tickProfileProperty({
tick,
name,
}: {
profileId: string
tick: number
name: string
profileId: string;
tick: number;
name: string;
}) {
const profile = await getProfile(profileId)
const profile = await getProfile(profileId);
if (!profile) {
throw new HttpError(404, `Profile not found ${profileId}`)
throw new HttpError(404, `Profile not found ${profileId}`);
}
const properties = (
typeof profile.properties === 'object' ? profile.properties ?? {} : {}
) as Record<string, number>
const value = name in properties ? properties[name] : 0
) as Record<string, number>;
const value = name in properties ? properties[name] : 0;
if (typeof value !== 'number') {
throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`)
throw new HttpError(
400,
`Property "${name}" on user is of type ${typeof value}`
);
}
if (typeof tick !== 'number') {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`)
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`);
}
await db.profile.update({
@@ -48,5 +51,5 @@ export async function tickProfileProperty({
[name]: value + tick,
},
},
})
}
});
}

View File

@@ -1,9 +1,9 @@
import { db } from "../db";
import { db } from '../db';
export function getProjectBySlug(slug: string) {
return db.project.findUniqueOrThrow({
where: {
slug
slug,
},
});
}
});
}

View File

@@ -1,27 +1,35 @@
import { type RouterOutputs } from "@/utils/api";
import { type timeRanges } from "@/utils/constants";
import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput, type zChartType, type zChartInputWithDates } from "@/utils/validation";
import { type Client, type Project } from "@prisma/client";
import { type TooltipProps } from "recharts";
import { type z } from "zod";
import { type RouterOutputs } from '@/utils/api';
import { type timeRanges } from '@/utils/constants';
import {
type zChartBreakdown,
type zChartEvent,
type zChartInput,
type zChartInputWithDates,
type zChartType,
type zTimeInterval,
} from '@/utils/validation';
import { type Client, type Project } from '@prisma/client';
import { type TooltipProps } from 'recharts';
import { type z } from 'zod';
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
export type IChartInput = z.infer<typeof zChartInput>
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>
export type IChartEvent = z.infer<typeof zChartEvent>
export type IChartEventFilter = IChartEvent['filters'][number]
export type IChartEventFilterValue = IChartEvent['filters'][number]['value'][number]
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
export type IInterval = z.infer<typeof zTimeInterval>
export type IChartType = z.infer<typeof zChartType>
export type IChartData = RouterOutputs["chart"]["chart"];
export type IChartRange = typeof timeRanges[number]['range'];
export type IChartInput = z.infer<typeof zChartInput>;
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue =
IChartEvent['filters'][number]['value'][number];
export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
export type IInterval = z.infer<typeof zTimeInterval>;
export type IChartType = z.infer<typeof zChartType>;
export type IChartData = RouterOutputs['chart']['chart'];
export type IChartRange = (typeof timeRanges)[number]['range'];
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
payload?: Array<T>
}
payload?: Array<T>;
};
export type IProject = Project
export type IProject = Project;
export type IClientWithProject = Client & {
project: IProject
}
project: IProject;
};

View File

@@ -4,16 +4,15 @@
*
* We also create a few inference helpers for input and output types.
*/
import { type TRPCClientErrorBase, httpLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import { type AppRouter } from "@/server/api/root";
import { toast } from "@/components/ui/use-toast";
import { toast } from '@/components/ui/use-toast';
import { type AppRouter } from '@/server/api/root';
import { httpLink, loggerLink, type TRPCClientErrorBase } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
import superjson from 'superjson';
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
if (typeof window !== 'undefined') return ''; // browser should use relative url
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
};
@@ -28,9 +27,9 @@ export const api = createTRPCNext<AppRouter>({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
enabled: typeof window !== "undefined",
enabled: typeof window !== 'undefined',
},
}
},
},
/**
* Transformer used for data de-serialization from the server.
@@ -47,8 +46,8 @@ export const api = createTRPCNext<AppRouter>({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpLink({
url: `${getBaseUrl()}/api/trpc`,
@@ -78,10 +77,9 @@ export type RouterInputs = inferRouterInputs<AppRouter>;
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function handleError(error: TRPCClientErrorBase<any>) {
toast({
title: 'Error',
description: error.message,
})
}
});
}

View File

@@ -1,9 +1,9 @@
import { toast } from "@/components/ui/use-toast"
import { toast } from '@/components/ui/use-toast';
export function clipboard(value: string | number) {
navigator.clipboard.writeText(value.toString())
navigator.clipboard.writeText(value.toString());
toast({
title: "Copied to clipboard",
title: 'Copied to clipboard',
description: value.toString(),
})
}
});
}

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
return twMerge(clsx(inputs));
}

View File

@@ -1,47 +1,47 @@
export const operators = {
is: "Is",
isNot: "Is not",
contains: "Contains",
doesNotContain: "Not contains",
is: 'Is',
isNot: 'Is not',
contains: 'Contains',
doesNotContain: 'Not contains',
};
export const chartTypes = {
linear: "Linear",
bar: "Bar",
pie: "Pie",
metric: "Metric",
area: "Area",
linear: 'Linear',
bar: 'Bar',
pie: 'Pie',
metric: 'Metric',
area: 'Area',
};
export const intervals = {
minute: "Minute",
day: "Day",
hour: "Hour",
month: "Month",
minute: 'Minute',
day: 'Day',
hour: 'Hour',
month: 'Month',
};
export const alphabetIds = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
] as const;
export const timeRanges = [
{ range: 0.3, title: "30m" },
{ range: 0.6, title: "1h" },
{ range: 0, title: "Today" },
{ range: 1, title: "24h" },
{ range: 7, title: "7d" },
{ range: 14, title: "14d" },
{ range: 30, title: "30d" },
{ range: 90, title: "3mo" },
{ range: 180, title: "6mo" },
{ range: 365, title: "1y" },
{ range: 0.3, title: '30m' },
{ range: 0.6, title: '1h' },
{ range: 0, title: 'Today' },
{ range: 1, title: '24h' },
{ range: 7, title: '7d' },
{ range: 14, title: '14d' },
{ range: 30, title: '30d' },
{ range: 90, title: '3mo' },
{ range: 180, title: '6mo' },
{ range: 365, title: '1y' },
] as const;

View File

@@ -10,11 +10,11 @@ export function dateDifferanceInDays(date1: Date, date2: Date) {
}
export function getLocale() {
if (typeof navigator === "undefined") {
return "en-US";
if (typeof navigator === 'undefined') {
return 'en-US';
}
return navigator.language ?? "en-US";
return navigator.language ?? 'en-US';
}
export function formatDate(date: Date) {
@@ -23,10 +23,10 @@ export function formatDate(date: Date) {
export function formatDateTime(date: Date) {
return new Intl.DateTimeFormat(getLocale(), {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(date);
}

View File

@@ -1,8 +1,7 @@
import { type IChartRange } from "@/types";
import { timeRanges } from "./constants";
import { type IChartRange } from '@/types';
import { timeRanges } from './constants';
export function getRangeLabel(range: IChartRange) {
return timeRanges.find(
(item) => item.range === range,
)?.title ?? null
}
return timeRanges.find((item) => item.range === range)?.title ?? null;
}

View File

@@ -1,9 +1,9 @@
export function toDots(
obj: Record<string, unknown>,
path = "",
path = ''
): Record<string, number | string | boolean> {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (typeof value === "object" && value !== null) {
if (typeof value === 'object' && value !== null) {
return {
...acc,
...toDots(value as Record<string, unknown>, `${path}${key}.`),
@@ -15,4 +15,4 @@ export function toDots(
[`${path}${key}`]: value,
};
}, {});
}
}

View File

@@ -1,4 +1,4 @@
import _slugify from 'slugify'
import _slugify from 'slugify';
const slugify = (str: string) => {
return _slugify(
@@ -9,10 +9,10 @@ const slugify = (str: string) => {
.replace('Å', 'A')
.replace('Ä', 'A')
.replace('Ö', 'O'),
{ lower: true, strict: true, trim: true },
)
}
{ lower: true, strict: true, trim: true }
);
};
export function slug(str: string): string {
return slugify(str)
return slugify(str);
}

View File

@@ -1,5 +1,7 @@
import resolveConfig from "tailwindcss/resolveConfig";
import tailwinConfig from "../../tailwind.config";
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwinConfig from '../../tailwind.config';
// @ts-expect-error
const config = resolveConfig(tailwinConfig);
@@ -7,7 +9,7 @@ export const theme = config.theme as any;
export function getChartColor(index: number): string {
const chartColors: string[] = Object.keys(theme?.colors ?? {})
.filter((key) => key.startsWith("chart-"))
.filter((key) => key.startsWith('chart-'))
.map((key) => theme.colors[key] as string);
return chartColors[index % chartColors.length]!;

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { operators, chartTypes, intervals } from "./constants";
import { z } from 'zod';
import { chartTypes, intervals, operators } from './constants';
function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
@@ -9,14 +10,14 @@ function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
export const zChartEvent = z.object({
id: z.string(),
name: z.string(),
segment: z.enum(["event", "user"]),
segment: z.enum(['event', 'user']),
filters: z.array(
z.object({
id: z.string(),
name: z.string(),
operator: z.enum(objectToZodEnums(operators)),
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
}),
})
),
});
export const zChartBreakdown = z.object({

View File

@@ -1,72 +1,70 @@
const colors = [
"#7856ff",
"#ff7557",
"#7fe1d8",
"#f8bc3c",
"#b3596e",
"#72bef4",
"#ffb27a",
"#0f7ea0",
"#3ba974",
"#febbb2",
"#cb80dc",
"#5cb7af",
'#7856ff',
'#ff7557',
'#7fe1d8',
'#f8bc3c',
'#b3596e',
'#72bef4',
'#ffb27a',
'#0f7ea0',
'#3ba974',
'#febbb2',
'#cb80dc',
'#5cb7af',
];
/** @type {import('tailwindcss').Config} */
const config = {
safelist: [
...colors.map((color) => `chart-${color}`),
],
darkMode: ["class"],
safelist: [...colors.map((color) => `chart-${color}`)],
darkMode: ['class'],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
padding: '2rem',
screens: {
"2xl": "1400px",
'2xl': '1400px',
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
...colors.reduce((acc, color, index) => {
return {
@@ -76,30 +74,30 @@ const config = {
}, {}),
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
boxShadow: {
DEFAULT: '0 5px 10px rgb(0 0 0 / 5%)'
DEFAULT: '0 5px 10px rgb(0 0 0 / 5%)',
},
keyframes: {
"accordion-down": {
'accordion-down': {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [require('tailwindcss-animate')],
};
export default config
export default config;

View File

@@ -1,19 +1,25 @@
{
"name": "@mixan/root",
"version": "1.0.0",
"keywords": [],
"author": "",
"private": true,
"license": "MIT",
"author": "Carl-Gerhard Lindesvärd",
"packageManager": "pnpm@8.7.6",
"license": "ISC",
"module": "index.ts",
"type": "module",
"scripts": {
"dev": "pnpm -r dev"
"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\"",
"lint": "pnpm -r lint",
"lint:fix": "pnpm -r lint --fix",
"lint:workspace": "pnpm dlx sherif@latest",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"semver": "^7.5.4"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.2.0"
}
}
}

View File

@@ -1,43 +1,43 @@
import {
import type {
EventPayload,
MixanErrorResponse,
ProfilePayload,
} from '@mixan/types'
} from '@mixan/types';
type NewMixanOptions = {
url: string
clientId: string
clientSecret: string
batchInterval?: number
maxBatchSize?: number
sessionTimeout?: number
verbose?: boolean
saveProfileId: (profiId: string) => void
getProfileId: () => (string | null)
removeProfileId: () => void
interface NewMixanOptions {
url: string;
clientId: string;
clientSecret: string;
batchInterval?: number;
maxBatchSize?: number;
sessionTimeout?: number;
verbose?: boolean;
saveProfileId: (profiId: string) => void;
getProfileId: () => string | null;
removeProfileId: () => void;
}
type MixanOptions = Required<NewMixanOptions>
type MixanOptions = Required<NewMixanOptions>;
class Fetcher {
private url: string
private clientId: string
private clientSecret: string
private logger: (...args: any[]) => void
private url: string;
private clientId: string;
private clientSecret: string;
private logger: (...args: any[]) => void;
constructor(options: MixanOptions) {
this.url = options.url
this.clientId = options.clientId
this.clientSecret = options.clientSecret
this.logger = options.verbose ? console.log : () => {}
this.url = options.url;
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.logger = options.verbose ? console.log : () => {};
}
post<Response extends unknown>(
post<PostData, PostResponse>(
path: string,
data: Record<string, any> = {},
options: RequestInit = {}
): Promise<Response | null> {
const url = `${this.url}${path}`
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2))
data?: PostData,
options?: RequestInit
): Promise<PostResponse | null> {
const url = `${this.url}${path}`;
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2));
return fetch(url, {
headers: {
['mixan-client-id']: this.clientId,
@@ -45,103 +45,109 @@ class Fetcher {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(data),
...options,
body: JSON.stringify(data ?? {}),
...(options ?? {}),
})
.then(async (res) => {
const response = await res.json() as (MixanErrorResponse | Response)
const response = (await res.json()) as
| MixanErrorResponse
| PostResponse;
if(!response) {
return null
if (!response) {
return null;
}
if (typeof response === 'object' && 'status' in response && response.status === 'error') {
if (
typeof response === 'object' &&
'status' in response &&
response.status === 'error'
) {
this.logger(
`Mixan request failed: [${options.method || 'POST'}] ${url}`,
`Mixan request failed: [${options?.method ?? 'POST'}] ${url}`,
JSON.stringify(response, null, 2)
)
return null
);
return null;
}
return response as Response
return response as PostResponse;
})
.catch(() => {
this.logger(
`Mixan request failed: [${options.method || 'POST'}] ${url}`
)
return null
})
`Mixan request failed: [${options?.method ?? 'POST'}] ${url}`
);
return null;
});
}
}
class Batcher<T extends any> {
queue: T[] = []
timer?: NodeJS.Timeout
callback: (queue: T[]) => void
maxBatchSize: number
batchInterval: number
class Batcher<T> {
queue: T[] = [];
timer?: NodeJS.Timeout;
callback: (queue: T[]) => void;
maxBatchSize: number;
batchInterval: number;
constructor(options: MixanOptions, callback: (queue: T[]) => void) {
this.callback = callback
this.maxBatchSize = options.maxBatchSize
this.batchInterval = options.batchInterval
this.callback = callback;
this.maxBatchSize = options.maxBatchSize;
this.batchInterval = options.batchInterval;
}
add(payload: T) {
this.queue.push(payload)
this.flush()
this.queue.push(payload);
this.flush();
}
flush() {
if (this.timer) {
clearTimeout(this.timer)
clearTimeout(this.timer);
}
if (this.queue.length === 0) {
return
return;
}
if (this.queue.length >= this.maxBatchSize) {
this.send()
return
this.send();
return;
}
this.timer = setTimeout(this.send.bind(this), this.batchInterval)
this.timer = setTimeout(this.send.bind(this), this.batchInterval);
}
send() {
if (this.timer) {
clearTimeout(this.timer)
clearTimeout(this.timer);
}
if (this.queue.length > 0) {
this.callback(this.queue)
this.queue = []
this.callback(this.queue);
this.queue = [];
}
}
}
export class Mixan {
private fetch: Fetcher
private eventBatcher: Batcher<EventPayload>
private profileId?: string
private options: MixanOptions
private logger: (...args: any[]) => void
private globalProperties: Record<string, any> = {}
private lastEventAt?: string
private lastScreenViewAt?: string
private fetch: Fetcher;
private eventBatcher: Batcher<EventPayload>;
private profileId?: string;
private options: MixanOptions;
private logger: (...args: any[]) => void;
private globalProperties: Record<string, unknown> = {};
private lastEventAt?: string;
private lastScreenViewAt?: string;
constructor(options: NewMixanOptions) {
this.logger = options.verbose ? console.log : () => {}
this.logger = options.verbose ? console.log : () => {};
this.options = {
sessionTimeout: 1000 * 60 * 30,
verbose: false,
batchInterval: 10000,
maxBatchSize: 10,
...options,
}
};
this.fetch = new Fetcher(this.options)
this.fetch = new Fetcher(this.options);
this.eventBatcher = new Batcher(this.options, (queue) => {
this.fetch.post(
'/events',
@@ -151,103 +157,106 @@ export class Mixan {
...this.globalProperties,
...item.properties,
},
profileId: item.profileId || this.profileId || null,
profileId: item.profileId ?? this.profileId ?? null,
}))
)
})
);
});
}
timestamp() {
return new Date().toISOString()
return new Date().toISOString();
}
init() {
this.logger('Mixan: Init')
this.setAnonymousUser()
this.logger('Mixan: Init');
this.setAnonymousUser();
}
event(name: string, properties: Record<string, any> = {}) {
const now = new Date()
event(name: string, properties: Record<string, unknown> = {}) {
const now = new Date();
const isSessionStart =
now.getTime() - new Date(this.lastEventAt ?? '1970-01-01').getTime() >
this.options.sessionTimeout
this.options.sessionTimeout;
if (isSessionStart) {
this.logger('Mixan: Session start')
this.logger('Mixan: Session start');
this.eventBatcher.add({
name: 'session_start',
time: this.timestamp(),
properties: {},
profileId: this.profileId || null,
})
profileId: this.profileId ?? null,
});
}
this.logger('Mixan: Queue event', name)
this.logger('Mixan: Queue event', name);
this.eventBatcher.add({
name,
properties,
time: this.timestamp(),
profileId: this.profileId || null,
})
this.lastEventAt = this.timestamp()
profileId: this.profileId ?? null,
});
this.lastEventAt = this.timestamp();
}
private async setAnonymousUser(retryCount: number = 0) {
const profileId = this.options.getProfileId()
private async setAnonymousUser(retryCount = 0) {
const profileId = this.options.getProfileId();
if (profileId) {
this.profileId = profileId
this.logger('Mixan: Use existing profile', this.profileId)
this.profileId = profileId;
this.logger('Mixan: Use existing profile', this.profileId);
} else {
const res = await this.fetch.post<{id: string}>('/profiles')
if(res) {
this.profileId = res.id
this.options.saveProfileId(res.id)
this.logger('Mixan: Create new profile', this.profileId)
} else if(retryCount < 2) {
const res = await this.fetch.post<undefined, { id: string }>('/profiles');
if (res) {
this.profileId = res.id;
this.options.saveProfileId(res.id);
this.logger('Mixan: Create new profile', this.profileId);
} else if (retryCount < 2) {
setTimeout(() => {
this.setAnonymousUser(retryCount + 1)
this.setAnonymousUser(retryCount + 1);
}, 500);
} else {
this.logger('Mixan: Failed to create new profile')
this.logger('Mixan: Failed to create new profile');
}
}
}
async setUser(profile: ProfilePayload) {
if (!this.profileId) {
return this.logger('Mixan: Set user failed, no profileId')
return this.logger('Mixan: Set user failed, no profileId');
}
this.logger('Mixan: Set user', profile)
this.logger('Mixan: Set user', profile);
await this.fetch.post(`/profiles/${this.profileId}`, profile, {
method: 'PUT',
})
});
}
async setUserProperty(name: string, value: any) {
async setUserProperty(
name: string,
value: string | number | boolean | Record<string, unknown> | unknown[]
) {
if (!this.profileId) {
return this.logger('Mixan: Set user property, no profileId')
return this.logger('Mixan: Set user property, no profileId');
}
this.logger('Mixan: Set user property', name, value)
this.logger('Mixan: Set user property', name, value);
await this.fetch.post(`/profiles/${this.profileId}`, {
properties: {
[name]: value,
},
})
});
}
async setGlobalProperties(properties: Record<string, any>) {
this.logger('Mixan: Set global properties', properties)
this.globalProperties = properties ?? {}
setGlobalProperties(properties: Record<string, unknown>) {
this.logger('Mixan: Set global properties', properties);
this.globalProperties = properties ?? {};
}
async increment(name: string, value: number = 1) {
async increment(name: string, value = 1) {
if (!this.profileId) {
this.logger('Mixan: Increment failed, no profileId')
return
this.logger('Mixan: Increment failed, no profileId');
return;
}
this.logger('Mixan: Increment user property', name, value)
this.logger('Mixan: Increment user property', name, value);
await this.fetch.post(
`/profiles/${this.profileId}/increment`,
{
@@ -257,16 +266,16 @@ export class Mixan {
{
method: 'PUT',
}
)
);
}
async decrement(name: string, value: number = 1) {
async decrement(name: string, value = 1) {
if (!this.profileId) {
this.logger('Mixan: Decrement failed, no profileId')
return
this.logger('Mixan: Decrement failed, no profileId');
return;
}
this.logger('Mixan: Decrement user property', name, value)
this.logger('Mixan: Decrement user property', name, value);
await this.fetch.post(
`/profiles/${this.profileId}/decrement`,
{
@@ -276,38 +285,38 @@ export class Mixan {
{
method: 'PUT',
}
)
);
}
async screenView(route: string, _properties?: Record<string, any>) {
const properties = _properties ?? {}
const now = new Date()
screenView(route: string, _properties?: Record<string, unknown>) {
const properties = _properties ?? {};
const now = new Date();
if (this.lastScreenViewAt) {
const last = new Date(this.lastScreenViewAt)
const diff = now.getTime() - last.getTime()
this.logger(`Mixan: Screen view duration: ${diff}ms`)
properties['duration'] = diff
const last = new Date(this.lastScreenViewAt);
const diff = now.getTime() - last.getTime();
this.logger(`Mixan: Screen view duration: ${diff}ms`);
properties.duration = diff;
}
this.lastScreenViewAt = now.toISOString()
await this.event('screen_view', {
this.lastScreenViewAt = now.toISOString();
this.event('screen_view', {
...properties,
route,
})
});
}
flush() {
this.logger('Mixan: Flushing events queue')
this.eventBatcher.send()
this.lastScreenViewAt = undefined
this.logger('Mixan: Flushing events queue');
this.eventBatcher.send();
this.lastScreenViewAt = undefined;
}
clear() {
this.logger('Mixan: Clear, send remaining events and remove profileId')
this.eventBatcher.send()
this.options.removeProfileId()
this.profileId = undefined
this.setAnonymousUser()
this.logger('Mixan: Clear, send remaining events and remove profileId');
this.eventBatcher.send();
this.options.removeProfileId();
this.profileId = undefined;
this.setAnonymousUser();
}
}

View File

@@ -3,12 +3,25 @@
"version": "0.0.1",
"type": "module",
"module": "index.ts",
"scripts": {
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mixan/types": "workspace:*"
},
"devDependencies": {
"@types/uuid": "^9.0.5",
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"tsup": "^7.2.0",
"typescript": "^5.0.0"
}
}
"typescript": "^5.2.0"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -13,9 +13,9 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"outDir": "dist",
"allowImportingTsExtensions": false,
"noEmit": false,
"noEmit": false
}
}

View File

@@ -1,8 +1,8 @@
import { defineConfig } from "tsup";
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ["index.ts"],
format: ["cjs", "esm"], // Build for commonJS and ESmodules
entry: ['index.ts'],
format: ['cjs', 'esm'], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts)
splitting: false,
sourcemap: true,

View File

@@ -1,76 +1,76 @@
export type MixanJson = Record<string, any>
export type MixanJson = Record<string, any>;
export type EventPayload = {
name: string
time: string
profileId: string | null
properties: MixanJson
export interface EventPayload {
name: string;
time: string;
profileId: string | null;
properties: MixanJson;
}
export type ProfilePayload = {
first_name?: string
last_name?: string
email?: string
avatar?: string
id?: string
properties?: MixanJson
export interface 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 interface ProfileIncrementPayload {
name: string;
value: number;
id: string;
}
export type ProfileDecrementPayload = {
name: string
value: number
id: string
export interface ProfileDecrementPayload {
name: string;
value: number;
id: string;
}
// Batching
export type BatchEvent = {
type: 'event'
payload: EventPayload
export interface BatchEvent {
type: 'event';
payload: EventPayload;
}
export type BatchProfile = {
type: 'profile'
payload: ProfilePayload
export interface BatchProfile {
type: 'profile';
payload: ProfilePayload;
}
export type BatchProfileIncrement = {
type: 'profile_increment'
payload: ProfileIncrementPayload
export interface BatchProfileIncrement {
type: 'profile_increment';
payload: ProfileIncrementPayload;
}
export type BatchProfileDecrement = {
type: 'profile_decrement'
payload: ProfileDecrementPayload
export interface BatchProfileDecrement {
type: 'profile_decrement';
payload: ProfileDecrementPayload;
}
export type BatchItem =
| BatchEvent
| BatchProfile
| BatchProfileIncrement
| BatchProfileDecrement
export type BatchPayload = Array<BatchItem>
| BatchProfileDecrement;
export type BatchPayload = BatchItem[];
export type MixanIssue = {
field: string
message: string
value: any
export interface MixanIssue {
field: string;
message: string;
value: any;
}
export type MixanErrorResponse = {
status: 'error'
code: number
message: string
issues?: Array<MixanIssue> | undefined
stack?: string | undefined
export interface MixanErrorResponse {
status: 'error';
code: number;
message: string;
issues?: MixanIssue[] | undefined;
stack?: string | undefined;
}
export type MixanResponse<T> = {
result: T
status: 'ok'
export interface MixanResponse<T> {
result: T;
status: 'ok';
}

View File

@@ -3,8 +3,22 @@
"version": "0.0.1",
"type": "module",
"module": "index.ts",
"scripts": {
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"tsup": "^7.2.0",
"typescript": "^5.0.0"
}
}
"typescript": "^5.2.0"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -1,8 +1,8 @@
import { defineConfig } from "tsup";
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ["index.ts"],
format: ["cjs", "esm"], // Build for commonJS and ESmodules
entry: ['index.ts'],
format: ['cjs', 'esm'], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts)
splitting: false,
sourcemap: false,

687
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
packages:
- 'apps/*'
- 'packages/*'
- 'tooling/*'

View File

@@ -1,7 +1,7 @@
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 { execSync } from 'node:child_process'
import semver from 'semver'
function savePackageJson(path: string, data: Record<string, any>) {
@@ -10,15 +10,15 @@ function savePackageJson(path: string, data: Record<string, any>) {
function main() {
const [version] = process.argv.slice(2)
if(!version) {
if (!version) {
return console.error('Missing version')
}
if(!semver.valid(version)) {
if (!semver.valid(version)) {
return console.error('Version is not valid')
}
const properties = {
private: false,
version,
@@ -53,12 +53,12 @@ function main() {
execSync('pnpm dlx tsup', {
cwd: './packages/types',
})
} catch(error) {
console.log('Build failed');
console.log(error);
} catch (error) {
console.log('Build failed')
console.log(error)
process.exit(1)
}
execSync('npm publish --access=public', {
cwd: './packages/sdk',
})

50
tooling/eslint/base.js Normal file
View File

@@ -0,0 +1,50 @@
/** @type {import("eslint").Linter.Config} */
const config = {
extends: [
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier',
],
env: {
es2022: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
},
plugins: ['@typescript-eslint', 'import'],
rules: {
'turbo/no-undeclared-env-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
'@typescript-eslint/no-misused-promises': [
2,
{ checksVoidReturn: { attributes: false } },
],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/ban-ts-comment": "off"
},
ignorePatterns: [
'**/.eslintrc.cjs',
'**/*.config.js',
'**/*.config.cjs',
'.next',
'dist',
'pnpm-lock.yaml',
],
reportUnusedDisableDirectives: true,
};
module.exports = config;

View File

@@ -0,0 +1,40 @@
{
"name": "@mixan/eslint-config",
"version": "0.1.0",
"private": true,
"license": "MIT",
"files": [
"./base.js",
"./react.js"
],
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-turbo": "^1.10.13",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
},
"devDependencies": {
"@types/eslint": "^8.44.2",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"typescript": "^5.2.0"
},
"eslintConfig": {
"root": true,
"extends": [
"./base.js"
]
},
"prettier": "@mixan/prettier-config"
}

24
tooling/eslint/react.js vendored Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('eslint').Linter.Config} */
const config = {
extends: [
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
rules: {
'react/prop-types': 'off',
},
globals: {
React: 'writable',
},
settings: {
react: {
version: 'detect',
},
},
env: {
browser: true,
},
};
module.exports = config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,28 @@
/** @typedef {import("prettier").Config} PrettierConfig */
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */
/** @type { PrettierConfig | SortImportsConfig } */
const config = {
plugins: [
'@ianvs/prettier-plugin-sort-imports',
],
importOrder: [
'^(react/(.*)$)|^(react$)|^(react-native(.*)$)',
'<THIRD_PARTY_MODULES>',
'',
'^@mixan/(.*)$',
'',
'^~/',
'^[../]',
'^[./]',
],
importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
importOrderTypeScriptVersion: '4.4.0',
singleQuote: true,
semi: true,
trailingComma: 'es5',
printWidth: 80,
tabWidth: 2,
};
export default config;

View File

@@ -0,0 +1,17 @@
{
"name": "@mixan/prettier-config",
"version": "0.1.0",
"private": true,
"main": "index.mjs",
"scripts": {
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
"prettier": "^3.0.3"
},
"devDependencies": {
"@mixan/tsconfig": "workspace:*",
"typescript": "^5.2.0"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noUncheckedIndexedAccess": true
},
"exclude": ["node_modules", "build", "dist"]
}

View File

@@ -0,0 +1,8 @@
{
"name": "@mixan/tsconfig",
"version": "0.1.0",
"private": true,
"files": [
"base.json"
]
}