Compare commits

..

No commits in common. "6367d94552e0c8e1b27e629d67fe9f45eca2c7ae" and "aefc36b476e050de210a96269ded0d15f3c046ba" have entirely different histories.

60 changed files with 1934 additions and 2538 deletions

3
.gitignore vendored
View file

@ -31,9 +31,6 @@ pm2.yml
*.log
.vscode
.tool-versions
.claude
tmpclaude*
nul
# debug
npm-debug.log*

View file

@ -1,96 +0,0 @@
# 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`.

View file

@ -61,18 +61,18 @@
".next/cache"
],
"dependencies": {
"@clickhouse/client": "^1.16.0",
"@clickhouse/client": "^1.12.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.2.0",
"@prisma/client": "^7.2.0",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@prisma/extension-read-replicas": "^0.5.0",
"@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.17",
"@tanstack/react-query": "^5.90.12",
"@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.3",
"fs-extra": "^11.3.2",
"immer": "^10.2.0",
"ipaddr.js": "^2.3.0",
"is-ci": "^3.0.1",
@ -101,19 +101,18 @@
"jszip": "^3.10.1",
"kafkajs": "^2.1.0",
"lucide-react": "^0.543.0",
"maxmind": "^5.0.3",
"maxmind": "^5.0.0",
"next": "^15.5.9",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"papaparse": "^5.5.3",
"pg": "^8.17.0",
"prisma": "^7.2.0",
"pg": "^8.16.3",
"prisma": "^7.1.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",
@ -121,15 +120,15 @@
"semver": "^7.7.3",
"serialize-error": "^12.0.0",
"thenby": "^1.3.4",
"ua-parser-js": "^2.0.8",
"ua-parser-js": "^2.0.6",
"uuid": "^13.0.0",
"zod": "^4.3.5",
"zustand": "^5.0.10"
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@biomejs/biome": "^2.3.8",
"@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^5.15.5",
"@netlify/plugin-nextjs": "^5.15.1",
"@rollup/plugin-alias": "^5.0.0",
"@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-json": "^6.0.0",
@ -138,8 +137,8 @@
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.8",
"@types/react": "^19.2.8",
"@types/node": "^24.9.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.2",
"@types/react-window": "^1.8.8",
"babel-plugin-react-compiler": "19.1.0-rc.2",
@ -154,9 +153,9 @@
"postcss-import": "^15.1.0",
"postcss-preset-env": "7.8.3",
"prompts": "2.4.2",
"rollup": "^4.55.1",
"rollup": "^4.52.5",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-delete": "^3.0.2",
"rollup-plugin-delete": "^3.0.1",
"rollup-plugin-dts": "^6.3.0",
"rollup-plugin-node-externals": "^8.1.1",
"rollup-plugin-peer-deps-external": "^2.2.4",
@ -165,7 +164,7 @@
"stylelint-config-css-modules": "^4.5.1",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0",
"tar": "^7.5.3",
"tar": "^6.1.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.1",
"tsup": "^8.5.0",

3378
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -67,6 +67,7 @@ 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
@ -87,6 +88,7 @@ model Website {
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@index([shareId])
@@index([createdBy])
@@map("website")
}
@ -337,16 +339,3 @@ 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")
}

View file

@ -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);
}
}

View file

@ -1,111 +1,21 @@
'use client';
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 { Loading } from '@umami/react-zen';
import { createContext, type ReactNode } from 'react';
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
import type { Board, BoardParameters } from '@/lib/types';
import type { Board } from '@/generated/prisma/client';
export type LayoutGetter = () => Partial<BoardParameters> | null;
export const BoardContext = createContext<Board>(null);
export interface BoardContextValue {
board: Partial<Board>;
updateBoard: (data: Partial<Board>) => void;
saveBoard: () => Promise<Board>;
isPending: boolean;
registerLayoutGetter: (getter: LayoutGetter) => void;
}
export function BoardProvider({ boardId, children }: { boardId: string; children: ReactNode }) {
const { data: board, isFetching, isLoading } = useBoardQuery(boardId);
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) {
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
return (
<BoardContext.Provider
value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }}
>
{children}
</BoardContext.Provider>
);
if (!board) {
return null;
}
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>;
}

View file

@ -1,322 +1,3 @@
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() {
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>
);
return <h1>i am bored.</h1>;
}

View file

@ -1,22 +1,21 @@
import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
import { useBoard, useMessages } from '@/components/hooks';
import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen';
import { 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 = (value: string) => {
updateBoard({ name: value });
const handleNameChange = (name: string) => {
//updateReport({ name: name || defaultName });
};
const handleDescriptionChange = (value: string) => {
updateBoard({ description: value });
const handleDescriptionChange = (description: string) => {
//updateReport({ description });
};
const handleSave = () => {
saveBoard();
};
return <h1>asdgfviybiyu8oaero8g9873qrgb875qh0g8</h1>;
return (
<Grid
@ -26,38 +25,39 @@ export function BoardHeader() {
border="bottom"
gapX="6"
>
asdfasdfds
<Column>
<Row>
<TextField
variant="quiet"
name="name"
value={board?.name ?? ''}
value={name}
defaultValue={name}
placeholder={defaultName}
onChange={handleNameChange}
autoComplete="off"
style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }}
>
<Heading size="4">{board?.name}</Heading>
<Heading size="4">{name}</Heading>
</TextField>
</Row>
<Row>
<TextField
variant="quiet"
name="description"
value={board?.description ?? ''}
value={description}
defaultValue={description}
placeholder={`+ ${formatMessage(labels.addDescription)}`}
autoComplete="off"
onChange={handleDescriptionChange}
style={{ width: '100%' }}
>
{board?.description}
{description}
</TextField>
</Row>
</Column>
<Column justifyContent="center" alignItems="flex-end">
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{formatMessage(labels.save)}
</LoadingButton>
<Button variant="primary">{formatMessage(labels.save)}</Button>
</Column>
</Grid>
);

View file

@ -1,6 +1,5 @@
'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';
@ -11,7 +10,6 @@ export function BoardPage({ boardId }: { boardId: string }) {
<PageBody>
<Column>
<BoardHeader />
<BoardBody />
</Column>
</PageBody>
</BoardProvider>

View file

@ -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 !== 'create' ? boardId : undefined} />;
return <BoardPage boardId={boardId} />;
}
export const metadata: Metadata = {

View file

@ -1,5 +1,5 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { useDeleteQuery, useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
@ -15,8 +15,7 @@ export function LinkDeleteButton({
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
const { touch } = useModified();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {

View file

@ -50,7 +50,6 @@ export function LinkEditForm({
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('links');
touch(`link:${linkId}`);
onSave?.();
onClose?.();
},

View file

@ -48,7 +48,6 @@ export function PixelEditForm({
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('pixels');
touch(`pixel:${pixelId}`);
onSave?.();
onClose?.();
},

View file

@ -1,4 +1,5 @@
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';
@ -25,8 +26,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(),
description: z.string().optional(),
parameters: z.object({}).passthrough().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -36,14 +37,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
}
const { boardId } = await params;
const { name, description, parameters } = body;
const { name, domain, shareId } = body;
if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized();
}
try {
const board = await updateBoard(boardId, { name, description, parameters });
const board = await updateBoard(boardId, { name, domain, shareId });
return Response.json(board);
} catch (e: any) {

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const data = await getGoal(websiteId, parameters as GoalParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const data = await getRetention(websiteId, parameters as RetentionParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);

View file

@ -18,8 +18,8 @@ export async function POST(request: Request) {
return unauthorized();
}
const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
const data = {
utm_source: [],

View file

@ -1,18 +1,18 @@
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import { json, notFound } from '@/lib/response';
import { getShareByCode } from '@/queries/prisma';
import { getSharedWebsite } from '@/queries/prisma';
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const { shareId } = await params;
const share = await getShareByCode(slug);
const website = await getSharedWebsite(shareId);
if (!share) {
if (!website) {
return notFound();
}
const data = { shareId: share.id };
const data = { websiteId: website.id };
const token = createToken(data, secret());
return json({ ...data, token });

View file

@ -1,80 +0,0 @@
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();
}

View file

@ -1,38 +0,0 @@
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);
}

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventDataEvents(websiteId, {
...filters,

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventDataFields(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventDataProperties(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventDataStats(websiteId, filters);

View file

@ -30,7 +30,7 @@ export async function GET(
}
const { propertyName } = query;
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventDataValues(websiteId, {
...filters,

View file

@ -29,7 +29,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getWebsiteEvents(websiteId, filters);

View file

@ -29,7 +29,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getEventStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
getEventMetrics(websiteId, { type: 'event' }, filters),

View file

@ -37,7 +37,7 @@ export async function GET(
}
const { type, limit, offset, search } = query;
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
if (search) {
filters[type] = `c.${search}`;

View file

@ -37,7 +37,7 @@ export async function GET(
}
const { type, limit, offset, search } = query;
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
if (search) {
filters[type] = `c.${search}`;

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, filters),

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getSessionDataProperties(websiteId, filters);

View file

@ -29,7 +29,7 @@ export async function GET(
}
const { propertyName } = query;
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getSessionDataValues(websiteId, {
...filters,

View file

@ -25,7 +25,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getSessionActivity(websiteId, sessionId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getWebsiteSessions(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const metrics = await getWebsiteSessionStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getWeeklyTraffic(websiteId, filters);

View file

@ -27,13 +27,11 @@ export async function GET(
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
const data = await getWebsiteStats(websiteId, filters);
const compare = filters.compare ?? 'prev';
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
const comparison = await getWebsiteStats(websiteId, {
...filters,

View file

@ -42,7 +42,7 @@ export async function GET(
value: segment.name,
}));
} else {
const filters = await getQueryFilters(query, websiteId);
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
}

View file

@ -1,6 +1,6 @@
import { useContext } from 'react';
import { BoardContext, type BoardContextValue } from '@/app/(main)/boards/BoardProvider';
import { BoardContext } from '@/app/(main)/boards/BoardProvider';
export function useBoard(): BoardContextValue {
export function useBoard() {
return useContext(BoardContext);
}

View file

@ -3,7 +3,7 @@ import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken;
export function useShareTokenQuery(slug: string): {
export function useShareTokenQuery(shareId: string): {
shareToken: any;
isLoading?: boolean;
error?: Error;
@ -11,9 +11,9 @@ export function useShareTokenQuery(slug: string): {
const shareToken = useApp(selector);
const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({
queryKey: ['share', slug],
queryKey: ['share', shareId],
queryFn: async () => {
const data = await get(`/share/${slug}`);
const data = await get(`/share/${shareId}`);
setShareToken(data);

View file

@ -1,6 +1,5 @@
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';
@ -25,16 +24,12 @@ 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, compare, ...filters },
],
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
enabled: !!websiteId,
...options,
});

View file

@ -95,13 +95,6 @@ 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,

View file

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

View file

@ -81,12 +81,12 @@ export function getRequestFilters(query: Record<string, any>) {
return result;
}
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
export async function setWebsiteDate(websiteId: string, userId: 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(website.userId);
const account = await fetchAccount(userId);
if (!account?.hasSubscription) {
data.startDate = maxDate(data.startDate, startOfMonth(subMonths(new Date(), 6)));
@ -103,12 +103,13 @@ export async function setWebsiteDate(websiteId: string, data: Record<string, any
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, dateRange);
await setWebsiteDate(websiteId, userId, dateRange);
if (params.segment) {
const segmentParams = (await getWebsiteSegment(websiteId, params.segment))

View file

@ -20,7 +20,7 @@ export const dateRangeParams = {
endDate: z.coerce.date().optional(),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
compare: z.enum(['prev', 'yoy']).optional(),
compare: z.string().optional(),
};
export const filterParams = {

View file

@ -1,6 +1,4 @@
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';
@ -143,29 +141,3 @@ 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;
}

View file

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

View file

@ -1,5 +1,4 @@
export * from './board';
export * from './entity';
export * from './link';
export * from './pixel';
export * from './report';

View file

@ -3,7 +3,6 @@ export * from './link';
export * from './pixel';
export * from './report';
export * from './segment';
export * from './share';
export * from './team';
export * from './teamUser';
export * from './user';

View file

@ -1,46 +0,0 @@
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 } });
}

View file

@ -16,6 +16,15 @@ 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;

View file

@ -41,18 +41,3 @@ 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;
}