mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Compare commits
23 commits
aefc36b476
...
6367d94552
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6367d94552 | ||
|
|
385bdd6734 | ||
|
|
30e48e3aaa | ||
|
|
30c3ba77cc | ||
|
|
ff6575ff54 | ||
|
|
d9f698ca42 | ||
|
|
68c56060b3 | ||
|
|
e08907d998 | ||
|
|
f97c840825 | ||
|
|
520c91c621 | ||
|
|
e3e2ed0435 | ||
|
|
29f2c7b7d4 | ||
|
|
a270b0afea | ||
|
|
889a404650 | ||
|
|
0fbd8a448d | ||
|
|
1e0620c544 | ||
|
|
b6013c3ee8 | ||
|
|
ebfbc282ee | ||
|
|
6d480d9c36 | ||
|
|
a049fbb5b0 | ||
|
|
6420f2c813 | ||
|
|
fbf03d6563 | ||
|
|
fbe031bfe9 |
60 changed files with 2538 additions and 1934 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -31,6 +31,9 @@ pm2.yml
|
|||
*.log
|
||||
.vscode
|
||||
.tool-versions
|
||||
.claude
|
||||
tmpclaude*
|
||||
nul
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
|||
96
CLAUDE.md
Normal file
96
CLAUDE.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Umami is a privacy-focused web analytics platform built with Next.js 15, React 19, and TypeScript. It serves as an alternative to Google Analytics, storing data in PostgreSQL (primary) with optional ClickHouse for time-series analytics.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server on port 3001 with Turbopack
|
||||
npm run build # Full production build (includes db setup, tracker, geo data)
|
||||
npm run start # Start production server
|
||||
|
||||
# Database
|
||||
npm run build-db # Generate Prisma client
|
||||
npm run update-db # Run Prisma migrations
|
||||
npm run check-db # Verify database connection
|
||||
npm run seed-data # Seed test data
|
||||
|
||||
# Code Quality
|
||||
npm run lint # Lint with Biome
|
||||
npm run format # Format with Biome
|
||||
npm run check # Format and lint with Biome
|
||||
npm run test # Run Jest tests
|
||||
|
||||
# Building specific parts
|
||||
npm run build-tracker # Build client-side tracking script (Rollup)
|
||||
npm run build-geo # Build geolocation database
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
- `src/app/` - Next.js App Router (routes and API endpoints)
|
||||
- `(main)/` - Authenticated app routes (dashboard, websites, teams, boards, etc.)
|
||||
- `(collect)/` - Data collection routes
|
||||
- `api/` - REST API endpoints
|
||||
- `src/components/` - React components (charts, forms, common UI, hooks)
|
||||
- `src/lib/` - Core utilities (auth, crypto, date, prisma helpers, redis)
|
||||
- `src/queries/` - Data access layer (Prisma queries and raw SQL)
|
||||
- `src/store/` - Zustand state stores (app, dashboard, websites, cache)
|
||||
- `src/tracker/` - Client-side tracking script (lightweight IIFE)
|
||||
- `prisma/` - Database schema and migrations
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**API Request Handling** - All API endpoints use Zod validation with `parseRequest`:
|
||||
```typescript
|
||||
const schema = z.object({ /* fields */ });
|
||||
const { body, error } = await parseRequest(request, schema);
|
||||
if (error) return error();
|
||||
```
|
||||
|
||||
**Authentication** - JWT tokens via Bearer header, share tokens via `x-umami-share-token` header for public dashboards. Role-based access: admin, manager, user.
|
||||
|
||||
**Data Fetching** - React Query with 60s stale time, no retry, no refetch on window focus.
|
||||
|
||||
**State Management** - Zustand for client state, localStorage for user preferences.
|
||||
|
||||
**Styling** - CSS Modules with CSS variables for theming (light/dark mode).
|
||||
|
||||
### Database
|
||||
|
||||
- **ORM**: Prisma 7.x with PostgreSQL adapter
|
||||
- **Schema**: `prisma/schema.prisma` - 14 models (User, Team, Website, Session, WebsiteEvent, EventData, etc.)
|
||||
- **Query helpers**: `src/lib/prisma.ts` has dynamic SQL generation functions (`getDateSQL`, `mapFilter`, `getSearchSQL`)
|
||||
- **Raw SQL**: Complex analytics queries use `{{param}}` template placeholders for safe binding
|
||||
|
||||
### Tracker Script
|
||||
|
||||
The tracking script in `src/tracker/index.js` is a standalone IIFE (~3-4KB) built with Rollup. It sends events to `/api/send`. Alternative script names can be configured via `TRACKER_SCRIPT_NAME` env var.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key variables in `.env`:
|
||||
```
|
||||
DATABASE_URL # PostgreSQL connection string (required)
|
||||
APP_SECRET # Encryption/signing secret
|
||||
CLICKHOUSE_URL # Optional ClickHouse for analytics
|
||||
REDIS_URL # Optional Redis for caching/sessions
|
||||
BASE_PATH # App base path (e.g., /analytics)
|
||||
DEBUG # Debug namespaces (e.g., umami:*)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18.18+
|
||||
- PostgreSQL 12.14+
|
||||
- pnpm (package manager)
|
||||
|
||||
## Git Workflow
|
||||
|
||||
Always ask for confirmation before running `git commit` or `git push`.
|
||||
37
package.json
37
package.json
|
|
@ -61,18 +61,18 @@
|
|||
".next/cache"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.12.0",
|
||||
"@clickhouse/client": "^1.16.0",
|
||||
"@date-fns/utc": "^1.2.0",
|
||||
"@dicebear/collection": "^9.2.3",
|
||||
"@dicebear/core": "^9.2.3",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@prisma/extension-read-replicas": "^0.5.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@umami/react-zen": "^0.216.0",
|
||||
"@umami/redis-client": "^0.30.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
"detect-browser": "^5.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.11",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fs-extra": "^11.3.3",
|
||||
"immer": "^10.2.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"is-ci": "^3.0.1",
|
||||
|
|
@ -101,18 +101,19 @@
|
|||
"jszip": "^3.10.1",
|
||||
"kafkajs": "^2.1.0",
|
||||
"lucide-react": "^0.543.0",
|
||||
"maxmind": "^5.0.0",
|
||||
"maxmind": "^5.0.3",
|
||||
"next": "^15.5.9",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^7.1.0",
|
||||
"pg": "^8.17.0",
|
||||
"prisma": "^7.2.0",
|
||||
"pure-rand": "^7.0.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-error-boundary": "^4.0.4",
|
||||
"react-intl": "^7.1.14",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-simple-maps": "^2.3.0",
|
||||
"react-use-measure": "^2.0.4",
|
||||
"react-window": "^1.8.6",
|
||||
|
|
@ -120,15 +121,15 @@
|
|||
"semver": "^7.7.3",
|
||||
"serialize-error": "^12.0.0",
|
||||
"thenby": "^1.3.4",
|
||||
"ua-parser-js": "^2.0.6",
|
||||
"ua-parser-js": "^2.0.8",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@formatjs/cli": "^4.2.29",
|
||||
"@netlify/plugin-nextjs": "^5.15.1",
|
||||
"@netlify/plugin-nextjs": "^5.15.5",
|
||||
"@rollup/plugin-alias": "^5.0.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.4",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
|
|
@ -137,8 +138,8 @@
|
|||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
|
|
@ -153,9 +154,9 @@
|
|||
"postcss-import": "^15.1.0",
|
||||
"postcss-preset-env": "7.8.3",
|
||||
"prompts": "2.4.2",
|
||||
"rollup": "^4.52.5",
|
||||
"rollup": "^4.55.1",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-delete": "^3.0.1",
|
||||
"rollup-plugin-delete": "^3.0.2",
|
||||
"rollup-plugin-dts": "^6.3.0",
|
||||
"rollup-plugin-node-externals": "^8.1.1",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
|
|
@ -164,7 +165,7 @@
|
|||
"stylelint-config-css-modules": "^4.5.1",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-recommended": "^14.0.0",
|
||||
"tar": "^6.1.2",
|
||||
"tar": "^7.5.3",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.5.0",
|
||||
|
|
|
|||
3378
pnpm-lock.yaml
generated
3378
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
41
prisma/migrations/15_add_share/migration.sql
Normal file
41
prisma/migrations/15_add_share/migration.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "share" (
|
||||
"share_id" UUID NOT NULL,
|
||||
"entity_id" UUID NOT NULL,
|
||||
"share_type" INTEGER NOT NULL,
|
||||
"slug" VARCHAR(100) NOT NULL,
|
||||
"parameters" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
|
||||
|
||||
-- MigrateData
|
||||
INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at)
|
||||
SELECT gen_random_uuid(),
|
||||
website_id,
|
||||
1,
|
||||
share_id,
|
||||
'{}'::jsonb,
|
||||
now()
|
||||
FROM "website"
|
||||
WHERE share_id IS NOT NULL;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "website_share_id_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "website_share_id_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "website" DROP COLUMN "share_id";
|
||||
|
|
@ -67,7 +67,6 @@ model Website {
|
|||
id String @id @unique @map("website_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
domain String? @db.VarChar(500)
|
||||
shareId String? @unique @map("share_id") @db.VarChar(50)
|
||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
teamId String? @map("team_id") @db.Uuid
|
||||
|
|
@ -88,7 +87,6 @@ model Website {
|
|||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([createdAt])
|
||||
@@index([shareId])
|
||||
@@index([createdBy])
|
||||
@@map("website")
|
||||
}
|
||||
|
|
@ -339,3 +337,16 @@ model Board {
|
|||
@@index([createdAt])
|
||||
@@map("board")
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id() @unique() @map("share_id") @db.Uuid
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
shareType Int @map("share_type") @db.Integer
|
||||
slug String @unique() @db.VarChar(100)
|
||||
parameters Json
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([entityId])
|
||||
@@map("share")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async function checkConnection() {
|
|||
|
||||
success('Database connection successful.');
|
||||
} catch (e) {
|
||||
throw new Error('Unable to connect to the database: ' + e.message);
|
||||
throw new Error(`Unable to connect to the database: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,111 @@
|
|||
'use client';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import { Loading, useToast } from '@umami/react-zen';
|
||||
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
|
||||
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
|
||||
import type { Board } from '@/generated/prisma/client';
|
||||
import type { Board, BoardParameters } from '@/lib/types';
|
||||
|
||||
export const BoardContext = createContext<Board>(null);
|
||||
export type LayoutGetter = () => Partial<BoardParameters> | null;
|
||||
|
||||
export function BoardProvider({ boardId, children }: { boardId: string; children: ReactNode }) {
|
||||
const { data: board, isFetching, isLoading } = useBoardQuery(boardId);
|
||||
export interface BoardContextValue {
|
||||
board: Partial<Board>;
|
||||
updateBoard: (data: Partial<Board>) => void;
|
||||
saveBoard: () => Promise<Board>;
|
||||
isPending: boolean;
|
||||
registerLayoutGetter: (getter: LayoutGetter) => void;
|
||||
}
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
export const BoardContext = createContext<BoardContextValue>(null);
|
||||
|
||||
const createDefaultBoard = (): Partial<Board> => ({
|
||||
name: '',
|
||||
description: '',
|
||||
parameters: {
|
||||
rows: [{ id: uuid(), columns: [{ id: uuid(), component: null }] }],
|
||||
},
|
||||
});
|
||||
|
||||
export function BoardProvider({ boardId, children }: { boardId?: string; children: ReactNode }) {
|
||||
const { data, isFetching, isLoading } = useBoardQuery(boardId);
|
||||
const { post, useMutation } = useApi();
|
||||
const { touch } = useModified();
|
||||
const { toast } = useToast();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { router, renderUrl } = useNavigation();
|
||||
|
||||
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
|
||||
const layoutGetterRef = useRef<LayoutGetter | null>(null);
|
||||
|
||||
const registerLayoutGetter = useCallback((getter: LayoutGetter) => {
|
||||
layoutGetterRef.current = getter;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setBoard(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: (boardData: Partial<Board>) => {
|
||||
if (boardData.id) {
|
||||
return post(`/boards/${boardData.id}`, boardData);
|
||||
}
|
||||
return post('/boards', { ...boardData, type: 'dashboard', slug: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateBoard = useCallback((data: Partial<Board>) => {
|
||||
setBoard(current => ({ ...current, ...data }));
|
||||
}, []);
|
||||
|
||||
const saveBoard = useCallback(async () => {
|
||||
const defaultName = formatMessage(labels.untitled);
|
||||
|
||||
// Get current layout sizes from BoardBody if registered
|
||||
const layoutData = layoutGetterRef.current?.();
|
||||
console.log('layoutData from getter:', layoutData);
|
||||
const parameters = layoutData ? { ...board.parameters, ...layoutData } : board.parameters;
|
||||
console.log('parameters to save:', parameters);
|
||||
|
||||
const result = await mutateAsync({
|
||||
...board,
|
||||
name: board.name || defaultName,
|
||||
parameters,
|
||||
});
|
||||
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('boards');
|
||||
|
||||
if (board.id) {
|
||||
touch(`board:${board.id}`);
|
||||
} else if (result?.id) {
|
||||
router.push(renderUrl(`/boards/${result.id}`));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [
|
||||
board,
|
||||
mutateAsync,
|
||||
toast,
|
||||
formatMessage,
|
||||
labels.untitled,
|
||||
messages.saved,
|
||||
touch,
|
||||
router,
|
||||
renderUrl,
|
||||
]);
|
||||
|
||||
if (boardId && isFetching && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (!board) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>;
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }}
|
||||
>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,322 @@
|
|||
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
||||
import { produce } from 'immer';
|
||||
import { Fragment, type ReactElement, useEffect, useRef } from 'react';
|
||||
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useBoard } from '@/components/hooks';
|
||||
import { ChevronDown, Minus, Plus, X } from '@/components/icons';
|
||||
import type { BoardColumn as BoardColumnType } from '@/lib/types';
|
||||
|
||||
const CATALOG = {
|
||||
text: {
|
||||
label: 'Text',
|
||||
component: BoardColumn,
|
||||
},
|
||||
};
|
||||
|
||||
const MIN_ROW_HEIGHT = 300;
|
||||
const MAX_ROW_HEIGHT = 600;
|
||||
const MIN_COLUMN_WIDTH = 300;
|
||||
const BUTTON_ROW_HEIGHT = 60;
|
||||
const MAX_COLUMNS = 4;
|
||||
|
||||
export function BoardBody() {
|
||||
return <h1>i am bored.</h1>;
|
||||
const { board, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
|
||||
const rowGroupRef = useRef<GroupImperativeHandle>(null);
|
||||
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
|
||||
|
||||
// Register a function to get current layout sizes on save
|
||||
useEffect(() => {
|
||||
registerLayoutGetter(() => {
|
||||
const rows = board?.parameters?.rows;
|
||||
console.log('Layout getter called, rows:', rows);
|
||||
console.log('rowGroupRef.current:', rowGroupRef.current);
|
||||
console.log('columnGroupRefs.current:', columnGroupRefs.current);
|
||||
|
||||
if (!rows?.length) return null;
|
||||
|
||||
const rowLayout = rowGroupRef.current?.getLayout();
|
||||
console.log('rowLayout:', rowLayout);
|
||||
|
||||
const updatedRows = rows.map(row => {
|
||||
const columnGroupRef = columnGroupRefs.current.get(row.id);
|
||||
const columnLayout = columnGroupRef?.getLayout();
|
||||
console.log(`Row ${row.id} columnLayout:`, columnLayout);
|
||||
|
||||
const updatedColumns = row.columns.map(col => ({
|
||||
...col,
|
||||
size: columnLayout?.[col.id],
|
||||
}));
|
||||
|
||||
return {
|
||||
...row,
|
||||
size: rowLayout?.[row.id],
|
||||
columns: updatedColumns,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('updatedRows:', updatedRows);
|
||||
return { rows: updatedRows };
|
||||
});
|
||||
}, [registerLayoutGetter, board?.parameters?.rows]);
|
||||
|
||||
const registerColumnGroupRef = (rowId: string, ref: GroupImperativeHandle | null) => {
|
||||
if (ref) {
|
||||
columnGroupRefs.current.set(rowId, ref);
|
||||
} else {
|
||||
columnGroupRefs.current.delete(rowId);
|
||||
}
|
||||
};
|
||||
|
||||
console.log({ board });
|
||||
|
||||
const handleAddRow = () => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
if (!draft.rows) {
|
||||
draft.rows = [];
|
||||
}
|
||||
draft.rows.push({ id: uuid(), columns: [{ id: uuid(), component: null }] });
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveRow = (id: string) => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
if (!draft.rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
draft.rows = draft.rows.filter(row => row?.id !== id);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveRowUp = (id: string) => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
if (!draft.rows) return;
|
||||
|
||||
const index = draft.rows.findIndex(row => row.id === id);
|
||||
if (index > 0) {
|
||||
const temp = draft.rows[index - 1];
|
||||
draft.rows[index - 1] = draft.rows[index];
|
||||
draft.rows[index] = temp;
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveRowDown = (id: string) => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
if (!draft.rows) return;
|
||||
|
||||
const index = draft.rows.findIndex(row => row.id === id);
|
||||
if (index < draft.rows.length - 1) {
|
||||
const temp = draft.rows[index + 1];
|
||||
draft.rows[index + 1] = draft.rows[index];
|
||||
draft.rows[index] = temp;
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const rows = board?.parameters?.rows ?? [];
|
||||
const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
|
||||
|
||||
return (
|
||||
<Group groupRef={rowGroupRef} orientation="vertical" style={{ minHeight }}>
|
||||
{rows.map((row, index) => (
|
||||
<Fragment key={row.id}>
|
||||
<Panel
|
||||
id={row.id}
|
||||
minSize={MIN_ROW_HEIGHT}
|
||||
maxSize={MAX_ROW_HEIGHT}
|
||||
defaultSize={row.size}
|
||||
>
|
||||
<BoardRow
|
||||
{...row}
|
||||
rowId={row.id}
|
||||
rowIndex={index}
|
||||
rowCount={rows?.length}
|
||||
onRemove={handleRemoveRow}
|
||||
onMoveUp={handleMoveRowUp}
|
||||
onMoveDown={handleMoveRowDown}
|
||||
onRegisterRef={registerColumnGroupRef}
|
||||
/>
|
||||
</Panel>
|
||||
{index < rows?.length - 1 && <Separator />}
|
||||
</Fragment>
|
||||
))}
|
||||
<Panel minSize={BUTTON_ROW_HEIGHT}>
|
||||
<Row padding="3">
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="outline" onPress={handleAddRow}>
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="bottom">Add row</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Row>
|
||||
</Panel>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardRow({
|
||||
rowId,
|
||||
rowIndex,
|
||||
rowCount,
|
||||
columns,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRegisterRef,
|
||||
}: {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
rowCount: number;
|
||||
columns: BoardColumnType[];
|
||||
onRemove?: (id: string) => void;
|
||||
onMoveUp?: (id: string) => void;
|
||||
onMoveDown?: (id: string) => void;
|
||||
onRegisterRef?: (rowId: string, ref: GroupImperativeHandle | null) => void;
|
||||
}) {
|
||||
const { board, updateBoard } = useBoard();
|
||||
|
||||
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
|
||||
onRegisterRef?.(rowId, ref);
|
||||
};
|
||||
|
||||
const handleAddColumn = () => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
const rowIndex = draft.rows.findIndex(row => row.id === rowId);
|
||||
const row = draft.rows[rowIndex];
|
||||
|
||||
if (!row) {
|
||||
draft.rows[rowIndex] = { id: uuid(), columns: [] };
|
||||
}
|
||||
row.columns.push({ id: uuid(), component: null });
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (columnId: string) => {
|
||||
updateBoard({
|
||||
parameters: produce(board.parameters, draft => {
|
||||
const row = draft.rows.find(row => row.id === rowId);
|
||||
if (row) {
|
||||
row.columns = row.columns.filter(col => col.id !== columnId);
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
|
||||
{columns?.map((column, index) => (
|
||||
<Fragment key={column.id}>
|
||||
<Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
|
||||
<BoardColumn
|
||||
{...column}
|
||||
onRemove={handleRemoveColumn}
|
||||
canRemove={columns?.length > 1}
|
||||
/>
|
||||
</Panel>
|
||||
{index < columns?.length - 1 && <Separator />}
|
||||
</Fragment>
|
||||
))}
|
||||
<Column alignSelf="center" padding="3" gap="1">
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}>
|
||||
<Icon rotate={180}>
|
||||
<ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="top">Move row up</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={handleAddColumn}
|
||||
isDisabled={columns?.length >= MAX_COLUMNS}
|
||||
>
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="left">Add column</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="outline" onPress={() => onRemove?.(rowId)}>
|
||||
<Icon>
|
||||
<Minus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="left">Remove row</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => onMoveDown?.(rowId)}
|
||||
isDisabled={rowIndex === rowCount - 1}
|
||||
>
|
||||
<Icon>
|
||||
<ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="bottom">Move row down</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Column>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardColumn({
|
||||
id,
|
||||
component,
|
||||
onRemove,
|
||||
canRemove = true,
|
||||
}: {
|
||||
id: string;
|
||||
component?: ReactElement;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove?: boolean;
|
||||
}) {
|
||||
const handleAddComponent = () => {};
|
||||
|
||||
return (
|
||||
<Column
|
||||
marginTop="3"
|
||||
marginLeft="3"
|
||||
width="100%"
|
||||
height="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="3"
|
||||
position="relative"
|
||||
>
|
||||
{canRemove && (
|
||||
<Box position="absolute" top="10px" right="20px" zIndex={100}>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="quiet" onPress={() => onRemove?.(id)}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip>Remove column</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Box>
|
||||
)}
|
||||
<Button variant="outline" onPress={handleAddComponent}>
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
|
||||
import { useBoard, useMessages } from '@/components/hooks';
|
||||
|
||||
export function BoardHeader() {
|
||||
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const defaultName = formatMessage(labels.untitled);
|
||||
const name = '';
|
||||
const description = '';
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
//updateReport({ name: name || defaultName });
|
||||
const handleNameChange = (value: string) => {
|
||||
updateBoard({ name: value });
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
//updateReport({ description });
|
||||
const handleDescriptionChange = (value: string) => {
|
||||
updateBoard({ description: value });
|
||||
};
|
||||
|
||||
return <h1>asdgfviybiyu8oaero8g9873qrgb875qh0g8</h1>;
|
||||
const handleSave = () => {
|
||||
saveBoard();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
|
|
@ -25,39 +26,38 @@ export function BoardHeader() {
|
|||
border="bottom"
|
||||
gapX="6"
|
||||
>
|
||||
asdfasdfds
|
||||
<Column>
|
||||
<Row>
|
||||
<TextField
|
||||
variant="quiet"
|
||||
name="name"
|
||||
value={name}
|
||||
defaultValue={name}
|
||||
value={board?.name ?? ''}
|
||||
placeholder={defaultName}
|
||||
onChange={handleNameChange}
|
||||
autoComplete="off"
|
||||
style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }}
|
||||
>
|
||||
<Heading size="4">{name}</Heading>
|
||||
<Heading size="4">{board?.name}</Heading>
|
||||
</TextField>
|
||||
</Row>
|
||||
<Row>
|
||||
<TextField
|
||||
variant="quiet"
|
||||
name="description"
|
||||
value={description}
|
||||
defaultValue={description}
|
||||
value={board?.description ?? ''}
|
||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
||||
autoComplete="off"
|
||||
onChange={handleDescriptionChange}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{description}
|
||||
{board?.description}
|
||||
</TextField>
|
||||
</Row>
|
||||
</Column>
|
||||
<Column justifyContent="center" alignItems="flex-end">
|
||||
<Button variant="primary">{formatMessage(labels.save)}</Button>
|
||||
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
|
||||
{formatMessage(labels.save)}
|
||||
</LoadingButton>
|
||||
</Column>
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { BoardBody } from '@/app/(main)/boards/[boardId]/BoardBody';
|
||||
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
|
||||
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
|
|
@ -10,6 +11,7 @@ export function BoardPage({ boardId }: { boardId: string }) {
|
|||
<PageBody>
|
||||
<Column>
|
||||
<BoardHeader />
|
||||
<BoardBody />
|
||||
</Column>
|
||||
</PageBody>
|
||||
</BoardProvider>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { BoardPage } from './BoardPage';
|
|||
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
|
||||
const { boardId } = await params;
|
||||
|
||||
return <BoardPage boardId={boardId} />;
|
||||
return <BoardPage boardId={boardId !== 'create' ? boardId : undefined} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
||||
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { messages } from '@/components/messages';
|
||||
|
|
@ -15,7 +15,8 @@ export function LinkDeleteButton({
|
|||
onSave?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
|
||||
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleConfirm = async (close: () => void) => {
|
||||
await mutateAsync(null, {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function LinkEditForm({
|
|||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('links');
|
||||
touch(`link:${linkId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function PixelEditForm({
|
|||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('pixels');
|
||||
touch(`pixel:${pixelId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { z } from 'zod';
|
||||
import { SHARE_ID_REGEX } from '@/lib/constants';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
||||
|
|
@ -26,8 +25,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ boar
|
|||
export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
||||
const schema = z.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||
description: z.string().optional(),
|
||||
parameters: z.object({}).passthrough().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
@ -37,14 +36,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
|
|||
}
|
||||
|
||||
const { boardId } = await params;
|
||||
const { name, domain, shareId } = body;
|
||||
const { name, description, parameters } = body;
|
||||
|
||||
if (!(await canUpdateBoard(auth, boardId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const board = await updateBoard(boardId, { name, domain, shareId });
|
||||
const board = await updateBoard(boardId, { name, description, parameters });
|
||||
|
||||
return Response.json(board);
|
||||
} catch (e: any) {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
|
||||
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
|
||||
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
|
||||
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
|
||||
const data = await getGoal(websiteId, parameters as GoalParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
|
||||
const data = await getRetention(websiteId, parameters as RetentionParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
|
||||
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||
const filters = await getQueryFilters(body.filters, websiteId);
|
||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
||||
|
||||
const data = {
|
||||
utm_source: [],
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { secret } from '@/lib/crypto';
|
||||
import { createToken } from '@/lib/jwt';
|
||||
import { json, notFound } from '@/lib/response';
|
||||
import { getSharedWebsite } from '@/queries/prisma';
|
||||
import { getShareByCode } from '@/queries/prisma';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
||||
const { shareId } = await params;
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const website = await getSharedWebsite(shareId);
|
||||
const share = await getShareByCode(slug);
|
||||
|
||||
if (!website) {
|
||||
if (!share) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data = { websiteId: website.id };
|
||||
const data = { shareId: share.id };
|
||||
const token = createToken(data, secret());
|
||||
|
||||
return json({ ...data, token });
|
||||
80
src/app/api/share/id/[shareId]/route.ts
Normal file
80
src/app/api/share/id/[shareId]/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import z from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, ok, unauthorized } from '@/lib/response';
|
||||
import { anyObjectParam } from '@/lib/schema';
|
||||
import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions';
|
||||
import { deleteShare, getShare, updateShare } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { shareId } = await params;
|
||||
|
||||
const share = await getShare(shareId);
|
||||
|
||||
if (!(await canViewEntity(auth, share.entityId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
return json(share);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
||||
const schema = z.object({
|
||||
slug: z.string().max(100),
|
||||
parameters: anyObjectParam,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { shareId } = await params;
|
||||
const { slug, parameters } = body;
|
||||
|
||||
const share = await getShare(shareId);
|
||||
|
||||
if (!share) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!(await canUpdateEntity(auth, share.entityId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const result = await updateShare(shareId, {
|
||||
slug,
|
||||
parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ shareId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { shareId } = await params;
|
||||
|
||||
const share = await getShare(shareId);
|
||||
|
||||
if (!(await canDeleteEntity(auth, share.entityId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
await deleteShare(shareId);
|
||||
|
||||
return ok();
|
||||
}
|
||||
38
src/app/api/share/route.ts
Normal file
38
src/app/api/share/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import z from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { anyObjectParam } from '@/lib/schema';
|
||||
import { canUpdateEntity } from '@/permissions';
|
||||
import { createShare } from '@/queries/prisma';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
entityId: z.uuid(),
|
||||
shareType: z.coerce.number().int(),
|
||||
slug: z.string().max(100),
|
||||
parameters: anyObjectParam,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { entityId, shareType, slug, parameters } = body;
|
||||
|
||||
if (!(await canUpdateEntity(auth, entityId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const share = await createShare({
|
||||
id: uuid(),
|
||||
entityId,
|
||||
shareType,
|
||||
slug,
|
||||
parameters,
|
||||
});
|
||||
|
||||
return json(share);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventDataEvents(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventDataFields(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventDataProperties(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventDataStats(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
const { propertyName } = query;
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventDataValues(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getWebsiteEvents(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventStats(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||
getEventMetrics(websiteId, { type: 'event' }, filters),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
const { type, limit, offset, search } = query;
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
if (search) {
|
||||
filters[type] = `c.${search}`;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
const { type, limit, offset, search } = query;
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
if (search) {
|
||||
filters[type] = `c.${search}`;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const [pageviews, sessions] = await Promise.all([
|
||||
getPageviewStats(websiteId, filters),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getSessionDataProperties(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
|||
}
|
||||
|
||||
const { propertyName } = query;
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getSessionDataValues(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getSessionActivity(websiteId, sessionId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getWebsiteSessions(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const metrics = await getWebsiteSessionStats(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getWeeklyTraffic(websiteId, filters);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getWebsiteStats(websiteId, filters);
|
||||
|
||||
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
|
||||
const compare = filters.compare ?? 'prev';
|
||||
|
||||
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
|
||||
|
||||
const comparison = await getWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export async function GET(
|
|||
value: segment.name,
|
||||
}));
|
||||
} else {
|
||||
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useContext } from 'react';
|
||||
import { BoardContext } from '@/app/(main)/boards/BoardProvider';
|
||||
import { BoardContext, type BoardContextValue } from '@/app/(main)/boards/BoardProvider';
|
||||
|
||||
export function useBoard() {
|
||||
export function useBoard(): BoardContextValue {
|
||||
return useContext(BoardContext);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useApi } from '../useApi';
|
|||
|
||||
const selector = (state: { shareToken: string }) => state.shareToken;
|
||||
|
||||
export function useShareTokenQuery(shareId: string): {
|
||||
export function useShareTokenQuery(slug: string): {
|
||||
shareToken: any;
|
||||
isLoading?: boolean;
|
||||
error?: Error;
|
||||
|
|
@ -11,9 +11,9 @@ export function useShareTokenQuery(shareId: string): {
|
|||
const shareToken = useApp(selector);
|
||||
const { get, useQuery } = useApi();
|
||||
const { isLoading, error } = useQuery({
|
||||
queryKey: ['share', shareId],
|
||||
queryKey: ['share', slug],
|
||||
queryFn: async () => {
|
||||
const data = await get(`/share/${shareId}`);
|
||||
const data = await get(`/share/${slug}`);
|
||||
|
||||
setShareToken(data);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useDateParameters } from '@/components/hooks/useDateParameters';
|
||||
import { useDateRange } from '@/components/hooks/useDateRange';
|
||||
import { useApi } from '../useApi';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
|
|
@ -24,12 +25,16 @@ export function useWebsiteStatsQuery(
|
|||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const { compare } = useDateRange();
|
||||
const filters = useFilterParameters();
|
||||
|
||||
return useQuery<WebsiteStatsData>({
|
||||
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
|
||||
queryKey: [
|
||||
'websites:stats',
|
||||
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
|
||||
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
|
||||
enabled: !!websiteId,
|
||||
...options,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ export const EVENT_TYPE = {
|
|||
pixelEvent: 4,
|
||||
} as const;
|
||||
|
||||
export const ENTITY_TYPE = {
|
||||
website: 1,
|
||||
link: 2,
|
||||
pixel: 3,
|
||||
board: 4,
|
||||
} as const;
|
||||
|
||||
export const DATA_TYPE = {
|
||||
string: 1,
|
||||
number: 2,
|
||||
|
|
|
|||
11
src/lib/entity.ts
Normal file
11
src/lib/entity.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { getLink, getPixel, getWebsite } from '@/queries/prisma';
|
||||
|
||||
export async function getEntity(entityId: string) {
|
||||
const website = await getWebsite(entityId);
|
||||
const link = await getLink(entityId);
|
||||
const pixel = await getPixel(entityId);
|
||||
|
||||
const entity = website || link || pixel;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
|
@ -81,12 +81,12 @@ export function getRequestFilters(query: Record<string, any>) {
|
|||
return result;
|
||||
}
|
||||
|
||||
export async function setWebsiteDate(websiteId: string, userId: string, data: Record<string, any>) {
|
||||
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
|
||||
const website = await fetchWebsite(websiteId);
|
||||
const cloudMode = !!process.env.CLOUD_MODE;
|
||||
|
||||
if (cloudMode && website && !website.teamId) {
|
||||
const account = await fetchAccount(userId);
|
||||
const account = await fetchAccount(website.userId);
|
||||
|
||||
if (!account?.hasSubscription) {
|
||||
data.startDate = maxDate(data.startDate, startOfMonth(subMonths(new Date(), 6)));
|
||||
|
|
@ -103,13 +103,12 @@ export async function setWebsiteDate(websiteId: string, userId: string, data: Re
|
|||
export async function getQueryFilters(
|
||||
params: Record<string, any>,
|
||||
websiteId?: string,
|
||||
userId?: string,
|
||||
): Promise<QueryFilters> {
|
||||
const dateRange = getRequestDateRange(params);
|
||||
const filters = getRequestFilters(params);
|
||||
|
||||
if (websiteId) {
|
||||
await setWebsiteDate(websiteId, userId, dateRange);
|
||||
await setWebsiteDate(websiteId, dateRange);
|
||||
|
||||
if (params.segment) {
|
||||
const segmentParams = (await getWebsiteSegment(websiteId, params.segment))
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const dateRangeParams = {
|
|||
endDate: z.coerce.date().optional(),
|
||||
timezone: timezoneParam.optional(),
|
||||
unit: unitParam.optional(),
|
||||
compare: z.string().optional(),
|
||||
compare: z.enum(['prev', 'yoy']).optional(),
|
||||
};
|
||||
|
||||
export const filterParams = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Board as PrismaBoard } from '@/generated/prisma/client';
|
||||
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
|
||||
import type { TIME_UNIT } from './date';
|
||||
|
||||
|
|
@ -141,3 +143,29 @@ export interface ApiError extends Error {
|
|||
code?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BoardComponent {
|
||||
id: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BoardColumn {
|
||||
id: string;
|
||||
component?: ReactElement;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface BoardRow {
|
||||
id: string;
|
||||
columns: BoardColumn[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface BoardParameters {
|
||||
rows?: BoardRow[];
|
||||
}
|
||||
|
||||
export interface Board extends Omit<PrismaBoard, 'parameters'> {
|
||||
parameters: BoardParameters;
|
||||
}
|
||||
|
|
|
|||
65
src/permissions/entity.ts
Normal file
65
src/permissions/entity.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { hasPermission } from '@/lib/auth';
|
||||
import { PERMISSIONS } from '@/lib/constants';
|
||||
import { getEntity } from '@/lib/entity';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import { getTeamUser } from '@/queries/prisma';
|
||||
|
||||
export async function canViewEntity({ user }: Auth, entityId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
|
||||
if (entity.teamId) {
|
||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
||||
|
||||
return !!teamUser;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canUpdateEntity({ user }: Auth, entityId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
|
||||
if (entity.teamId) {
|
||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canDeleteEntity({ user }: Auth, entityId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entity = await getEntity(entityId);
|
||||
|
||||
if (entity.userId) {
|
||||
return user.id === entity.userId;
|
||||
}
|
||||
|
||||
if (entity.teamId) {
|
||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './board';
|
||||
export * from './entity';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
export * from './report';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * from './link';
|
|||
export * from './pixel';
|
||||
export * from './report';
|
||||
export * from './segment';
|
||||
export * from './share';
|
||||
export * from './team';
|
||||
export * from './teamUser';
|
||||
export * from './user';
|
||||
|
|
|
|||
46
src/queries/prisma/share.ts
Normal file
46
src/queries/prisma/share.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function findShare(criteria: Prisma.ShareFindUniqueArgs) {
|
||||
return prisma.client.share.findUnique(criteria);
|
||||
}
|
||||
|
||||
export async function getShare(entityId: string) {
|
||||
return findShare({
|
||||
where: {
|
||||
id: entityId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getShareByCode(slug: string) {
|
||||
return findShare({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createShare(
|
||||
data: Prisma.ShareCreateInput | Prisma.ShareUncheckedCreateInput,
|
||||
) {
|
||||
return prisma.client.share.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateShare(
|
||||
shareId: string,
|
||||
data: Prisma.ShareUpdateInput | Prisma.ShareUncheckedUpdateInput,
|
||||
) {
|
||||
return prisma.client.share.update({
|
||||
where: {
|
||||
id: shareId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteShare(shareId: string) {
|
||||
return prisma.client.share.delete({ where: { id: shareId } });
|
||||
}
|
||||
|
|
@ -16,15 +16,6 @@ export async function getWebsite(websiteId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function getSharedWebsite(shareId: string) {
|
||||
return findWebsite({
|
||||
where: {
|
||||
shareId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
|
||||
const { search } = filters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
|
|
|||
|
|
@ -41,3 +41,18 @@ a:hover {
|
|||
border: 4px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Fix autofill background color to match dark theme */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--background-color) inset !important;
|
||||
transition: color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue