Merge branch 'dev' of https://github.com/umami-software/umami into analytics
This commit is contained in:
Generated
+1
@@ -6539,6 +6539,7 @@ packages:
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ async function checkDatabaseVersion() {
|
||||
|
||||
async function applyMigration() {
|
||||
if (!process.env.SKIP_DB_MIGRATION) {
|
||||
console.log(execSync('prisma migrate deploy').toString());
|
||||
const directUrl = process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL;
|
||||
console.log(execSync('prisma migrate deploy', { env: { ...process.env, DATABASE_URL: directUrl } }).toString());
|
||||
|
||||
success('Database is up to date.');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from
|
||||
import Link from '@/components/common/Link';
|
||||
import { useState } from 'react';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit, Trash } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
@@ -21,7 +22,11 @@ export function AdminTeamsTable({
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data} {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)} width="1fr">
|
||||
<DataColumn
|
||||
id="name"
|
||||
label={<SortableLabel label={t(labels.name)} sortKey="name" />}
|
||||
width="1fr"
|
||||
>
|
||||
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
|
||||
</DataColumn>
|
||||
<DataColumn id="websites" label={t(labels.members)} width="140px">
|
||||
@@ -41,7 +46,11 @@ export function AdminTeamsTable({
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)} width="160px">
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
width="160px"
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/
|
||||
import Link from '@/components/common/Link';
|
||||
import { useState } from 'react';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit, Trash } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
@@ -22,10 +23,14 @@ export function UsersTable({
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data} {...props}>
|
||||
<DataColumn id="username" label={t(labels.username)} width="2fr">
|
||||
<DataColumn
|
||||
id="username"
|
||||
label={<SortableLabel label={t(labels.username)} sortKey="username" />}
|
||||
width="2fr"
|
||||
>
|
||||
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
|
||||
</DataColumn>
|
||||
<DataColumn id="role" label={t(labels.role)}>
|
||||
<DataColumn id="role" label={<SortableLabel label={t(labels.role)} sortKey="role" />}>
|
||||
{(row: any) =>
|
||||
t(labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown)
|
||||
}
|
||||
@@ -33,7 +38,10 @@ export function UsersTable({
|
||||
<DataColumn id="websites" label={t(labels.websites)}>
|
||||
{(row: any) => row._count.websites}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)}>
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from '@/components/common/Link';
|
||||
import { useState } from 'react';
|
||||
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit, Trash, Users } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
@@ -14,14 +15,14 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data} {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{(row: any) => (
|
||||
<Text truncate>
|
||||
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
|
||||
</Text>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="domain" label={t(labels.domain)}>
|
||||
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />}>
|
||||
{(row: any) => <Text truncate>{row.domain}</Text>}
|
||||
</DataColumn>
|
||||
<DataColumn id="owner" label={t(labels.owner)}>
|
||||
@@ -45,7 +46,11 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)} width="180px">
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
width="180px"
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="50px">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
|
||||
import Link from '@/components/common/Link';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { BoardDeleteButton } from './BoardDeleteButton';
|
||||
import { BoardDesignButton } from './BoardDesignButton';
|
||||
@@ -12,16 +13,23 @@ export function BoardsTable(props: DataTableProps) {
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/boards/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="description" label={t(labels.description)} />
|
||||
<DataColumn id="type" label={t(labels.boardType)}>
|
||||
<DataColumn
|
||||
id="description"
|
||||
label={<SortableLabel label={t(labels.description)} sortKey="description" />}
|
||||
/>
|
||||
<DataColumn id="type" label={<SortableLabel label={t(labels.boardType)} sortKey="type" />}>
|
||||
{({ type }: any) => type ? type.charAt(0).toUpperCase() + type.slice(1) : ''}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)} width="200px">
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
width="200px"
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
|
||||
import Link from '@/components/common/Link';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||
import { LinkEditButton } from './LinkEditButton';
|
||||
@@ -17,23 +18,27 @@ export function LinksTable({ showActions, ...props }: LinksTableProps) {
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="slug" label={t(labels.link)}>
|
||||
<DataColumn id="slug" label={<SortableLabel label={t(labels.link)} sortKey="slug" />}>
|
||||
{({ slug }: any) => {
|
||||
const url = getSlugUrl(slug);
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="url" label={t(labels.destinationUrl)}>
|
||||
<DataColumn id="url" label={<SortableLabel label={t(labels.destinationUrl)} sortKey="url" />}>
|
||||
{({ url }: any) => {
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)} width="200px">
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
width="200px"
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
|
||||
import Link from '@/components/common/Link';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||
import { PixelEditButton } from './PixelEditButton';
|
||||
@@ -17,12 +18,12 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="url" label="URL">
|
||||
<DataColumn id="url" label={<SortableLabel label="URL" sortKey="slug" />}>
|
||||
{({ slug }: any) => {
|
||||
const url = getSlugUrl(slug);
|
||||
return (
|
||||
@@ -32,7 +33,10 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={t(labels.created)}>
|
||||
<DataColumn
|
||||
id="created"
|
||||
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
|
||||
>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
@@ -12,7 +13,7 @@ export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{renderLink}
|
||||
</DataColumn>
|
||||
<DataColumn id="owner" label={t(labels.owner)}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Row } from '@umami/react-zen';
|
||||
import Link from '@/components/common/Link';
|
||||
import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
|
||||
import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
@@ -18,10 +19,10 @@ export function TeamWebsitesTable({
|
||||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>}
|
||||
</DataColumn>
|
||||
<DataColumn id="domain" label={t(labels.domain)} />
|
||||
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
|
||||
<DataColumn id="createdBy" label={t(labels.createdBy)}>
|
||||
{(row: any) => row?.createUser?.username}
|
||||
</DataColumn>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
|
||||
import type { ReactNode } from 'react';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { SortableLabel } from '@/components/common/SortableLabel';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { SquarePen } from '@/components/icons';
|
||||
|
||||
@@ -17,10 +18,10 @@ export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTab
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="name" label={t(labels.name)}>
|
||||
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
|
||||
{renderLink}
|
||||
</DataColumn>
|
||||
<DataColumn id="domain" label={t(labels.domain)} />
|
||||
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
|
||||
{showActions && (
|
||||
<DataColumn id="action" label=" " align="end">
|
||||
{(row: any) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewAllTeams } from '@/permissions';
|
||||
import { getTeams } from '@/queries/prisma/team';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewUsers } from '@/permissions';
|
||||
import { getUsers } from '@/queries/prisma/user';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewAllWebsites } from '@/permissions';
|
||||
import { getWebsites } from '@/queries/prisma/website';
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { createBoard, getUserBoards } from '@/queries/prisma';
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { createLink, getUserLinks } from '@/queries/prisma';
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { pagingParams, sortingParams } from '@/lib/schema';
|
||||
import { getUserTeams } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { pagingParams, sortingParams } from '@/lib/schema';
|
||||
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...sortingParams,
|
||||
includeTeams: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { createPixel, getUserPixels } from '@/queries/prisma';
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewTeam } from '@/permissions';
|
||||
import { getTeamBoards } from '@/queries/prisma';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
const { teamId } = await params;
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewTeam } from '@/permissions';
|
||||
import { getTeamLinks } from '@/queries/prisma';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
const { teamId } = await params;
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewTeam } from '@/permissions';
|
||||
import { getTeamPixels } from '@/queries/prisma';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
const { teamId } = await params;
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canViewTeam } from '@/permissions';
|
||||
import { getTeamWebsites } from '@/queries/prisma';
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
});
|
||||
const { teamId } = await params;
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -5,13 +5,14 @@ import { fetchAccount } from '@/lib/load';
|
||||
import redis from '@/lib/redis';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { pagingParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeam } from '@/permissions';
|
||||
import { createTeam, getUserTeams } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { pagingParams, sortingParams } from '@/lib/schema';
|
||||
import { getUserTeams } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...sortingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
includeTeams: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
|
||||
import { fetchAccount } from '@/lib/load';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
|
||||
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
|
||||
@@ -15,6 +15,7 @@ export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
...sortingParams,
|
||||
includeTeams: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DataGrid({
|
||||
const { data, error, isLoading, isFetching } = query;
|
||||
const { router, updateParams, query: queryParams } = useNavigation();
|
||||
const [search, setSearch] = useState(queryParams?.search || data?.search || '');
|
||||
const showPager = allowPaging && data && (data.count > data.pageSize || data.isCapped);
|
||||
const showPager = allowPaging && data && data.count > 0;
|
||||
const { isMobile } = useMobile();
|
||||
const displayMode = isMobile ? 'cards' : undefined;
|
||||
|
||||
|
||||
@@ -11,11 +11,18 @@ export interface PagerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerProps) {
|
||||
export function Pager({
|
||||
page,
|
||||
pageSize,
|
||||
count,
|
||||
isCapped,
|
||||
onPageChange,
|
||||
}: PagerProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0;
|
||||
const lastPage = page === maxPage;
|
||||
const firstPage = page === 1;
|
||||
const showNavigation = maxPage > 1 || isCapped;
|
||||
|
||||
if (count === 0 || !maxPage) {
|
||||
return null;
|
||||
@@ -29,34 +36,40 @@ export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerPr
|
||||
}
|
||||
};
|
||||
|
||||
if (maxPage === 1 && !isCapped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayCount = isCapped ? `10,000+` : (+count).toLocaleString();
|
||||
|
||||
return (
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}>
|
||||
<Text>{t(labels.numberOfRecords, { x: displayCount })}</Text>
|
||||
<Row alignItems="center" justifyContent="flex-end" gap="3">
|
||||
<Text>
|
||||
{t(labels.pageOf, {
|
||||
current: page.toLocaleString(),
|
||||
total: maxPage.toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
<Row gap="1">
|
||||
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
|
||||
<Icon size="sm" rotate={180}>
|
||||
<ChevronRight />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
|
||||
<Icon size="sm">
|
||||
<ChevronRight />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1} wrap="wrap">
|
||||
<Text color="muted">{t(labels.numberOfRecords, { x: displayCount })}</Text>
|
||||
<Row
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
gap="3"
|
||||
wrap="nowrap"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{showNavigation && (
|
||||
<>
|
||||
<Text color="muted">
|
||||
{t(labels.pageOf, {
|
||||
current: page.toLocaleString(),
|
||||
total: maxPage.toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
<Row gap="1">
|
||||
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
|
||||
<Icon size="sm" rotate={180}>
|
||||
<ChevronRight />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
|
||||
<Icon size="sm">
|
||||
<ChevronRight />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon } from '@umami/react-zen';
|
||||
import { ChevronDown, ChevronUp } from '@/components/icons';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortableLabelProps {
|
||||
label: ReactNode;
|
||||
sortKey: string;
|
||||
defaultDirection?: SortDirection;
|
||||
}
|
||||
|
||||
export function SortableLabel({
|
||||
label,
|
||||
sortKey,
|
||||
defaultDirection = 'asc',
|
||||
}: SortableLabelProps) {
|
||||
const { router, query, updateParams } = useNavigation();
|
||||
const isActive = query.orderBy === sortKey;
|
||||
const isDescending = query.sortDescending === 'true';
|
||||
const direction = isActive ? (isDescending ? 'desc' : 'asc') : undefined;
|
||||
const activeColor = 'var(--text-primary)';
|
||||
|
||||
const getNextDirection = (): SortDirection => {
|
||||
if (!isActive) {
|
||||
return defaultDirection;
|
||||
}
|
||||
|
||||
return direction === 'desc' ? 'asc' : 'desc';
|
||||
};
|
||||
|
||||
const handleSort = () => {
|
||||
const nextDirection = getNextDirection();
|
||||
|
||||
router.push(
|
||||
updateParams({
|
||||
orderBy: sortKey,
|
||||
sortDescending: nextDirection === 'desc' ? 'true' : undefined,
|
||||
page: 1,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSort}
|
||||
className="inline-flex appearance-none items-center gap-1 border-0 bg-transparent p-0 text-inherit"
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
lineHeight: 0,
|
||||
color: 'var(--text-muted)',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: direction === 'asc' ? activeColor : undefined,
|
||||
opacity: direction === 'asc' ? 1 : 0.55,
|
||||
transform: 'scale(0.9)',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon size="sm" color={direction === 'asc' ? undefined : 'muted'}>
|
||||
<ChevronUp />
|
||||
</Icon>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: direction === 'desc' ? activeColor : undefined,
|
||||
opacity: direction === 'desc' ? 1 : 0.55,
|
||||
marginTop: '-6px',
|
||||
transform: 'scale(0.9)',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon size="sm" color={direction === 'desc' ? undefined : 'muted'}>
|
||||
<ChevronDown />
|
||||
</Icon>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useUserTeamsQuery(userId: string) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { get } = useApi();
|
||||
const { modified } = useModified(`teams`);
|
||||
|
||||
return useQuery({
|
||||
return usePagedQuery({
|
||||
queryKey: ['teams', { userId, modified }],
|
||||
queryFn: () => {
|
||||
return get(`/users/${userId}/teams`);
|
||||
queryFn: params => {
|
||||
return get(`/users/${userId}/teams`, params);
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigation } from './useNavigation';
|
||||
|
||||
export function usePageParameters() {
|
||||
const {
|
||||
query: { page, pageSize, search },
|
||||
query: { page, pageSize, search, orderBy, sortDescending },
|
||||
} = useNavigation();
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -11,6 +11,8 @@ export function usePageParameters() {
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
orderBy,
|
||||
sortDescending,
|
||||
};
|
||||
}, [page, pageSize, search]);
|
||||
}, [orderBy, page, pageSize, search, sortDescending]);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ export function usePagedQuery<TData = any, TError = Error>({
|
||||
queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
|
||||
}): UseQueryResult<PageResult<TData>, TError> {
|
||||
const {
|
||||
query: { page, search },
|
||||
query: { page, search, orderBy, sortDescending },
|
||||
} = useNavigation();
|
||||
const { useQuery } = useApi();
|
||||
|
||||
return useQuery<PageResult<TData>, TError>({
|
||||
queryKey: [...queryKey, page, search] as const,
|
||||
queryFn: () => queryFn({ page, search }),
|
||||
queryKey: [...queryKey, page, search, orderBy, sortDescending] as const,
|
||||
queryFn: () => queryFn({ page, search, orderBy, sortDescending }),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function DialogButton({
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxHeight: 'calc(100dvh - 40px)',
|
||||
maxHeight: 'min(80dvh, calc(100dvh - 40px))',
|
||||
overflowY: 'auto',
|
||||
padding: '32px',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { DATA_TYPE } from '../constants';
|
||||
import {
|
||||
flattenJSON,
|
||||
createKeyValue,
|
||||
isValidDateValue,
|
||||
getDataType,
|
||||
getStringValue,
|
||||
objectToArray,
|
||||
type KeyValueData,
|
||||
} from '../data';
|
||||
|
||||
describe('isValidDateValue', () => {
|
||||
test.each([
|
||||
['2024-01-15T10:30:00Z', true],
|
||||
['2024-01-15T10:30:00.123Z', true],
|
||||
['2024-01-15T10:30:00+02:00', true],
|
||||
['not-a-date', false],
|
||||
['2024/01/15', false],
|
||||
['', false],
|
||||
])('validates datetime strings correctly (%s → %s)', (input, expected) => {
|
||||
expect(isValidDateValue(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('returns false for non-string values', () => {
|
||||
expect(isValidDateValue(123 as any)).toBe(false);
|
||||
expect(isValidDateValue(null as any)).toBe(false);
|
||||
expect(isValidDateValue(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataType', () => {
|
||||
test.each([
|
||||
['string', 'string'],
|
||||
[123, 'number'],
|
||||
[true, 'boolean'],
|
||||
[null, 'object'],
|
||||
[[], 'object'],
|
||||
[{}, 'object'],
|
||||
['2024-01-15T10:30:00Z', 'date'],
|
||||
])('detects type correctly (%s → %s)', (input, expected) => {
|
||||
expect(getDataType(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyValue', () => {
|
||||
test('handles string values', () => {
|
||||
const result = createKeyValue('name', 'test');
|
||||
expect(result).toEqual({
|
||||
key: 'name',
|
||||
value: 'test',
|
||||
dataType: DATA_TYPE.string,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles number values', () => {
|
||||
const result = createKeyValue('count', 42);
|
||||
expect(result).toEqual({
|
||||
key: 'count',
|
||||
value: 42,
|
||||
dataType: DATA_TYPE.number,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles boolean values and converts to string', () => {
|
||||
expect(createKeyValue('active', true)).toEqual({
|
||||
key: 'active',
|
||||
value: 'true',
|
||||
dataType: DATA_TYPE.boolean,
|
||||
});
|
||||
expect(createKeyValue('active', false)).toEqual({
|
||||
key: 'active',
|
||||
value: 'false',
|
||||
dataType: DATA_TYPE.boolean,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles date strings', () => {
|
||||
const dateStr = '2024-01-15T10:30:00Z';
|
||||
const result = createKeyValue('timestamp', dateStr);
|
||||
expect(result).toEqual({
|
||||
key: 'timestamp',
|
||||
value: dateStr,
|
||||
dataType: DATA_TYPE.date,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles arrays and converts to JSON string', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = createKeyValue('items', arr);
|
||||
expect(result).toEqual({
|
||||
key: 'items',
|
||||
value: '[1,2,3]',
|
||||
dataType: DATA_TYPE.array,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles null values', () => {
|
||||
const result = createKeyValue('nullable', null);
|
||||
expect(result.dataType).toBe(DATA_TYPE.array);
|
||||
expect(result.value).toBe('null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStringValue', () => {
|
||||
test('formats number values with 4 decimal places', () => {
|
||||
expect(getStringValue('42', DATA_TYPE.number)).toBe('42.0000');
|
||||
expect(getStringValue('3.14159', DATA_TYPE.number)).toBe('3.1416');
|
||||
});
|
||||
|
||||
test('converts date values to ISO string', () => {
|
||||
const result = getStringValue('2024-01-15T10:30:00', DATA_TYPE.date);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('returns string values as-is for other types', () => {
|
||||
expect(getStringValue('test', DATA_TYPE.string)).toBe('test');
|
||||
expect(getStringValue('true', DATA_TYPE.boolean)).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectToArray', () => {
|
||||
test('converts object values to array', () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
const result = objectToArray(obj);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('returns empty array for empty object', () => {
|
||||
expect(objectToArray({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenJSON', () => {
|
||||
test('flattens simple object', () => {
|
||||
const input = {
|
||||
name: 'test',
|
||||
count: 42,
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'name', value: 'test' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'count', value: 42 }));
|
||||
});
|
||||
|
||||
test('flattens nested object with dot notation', () => {
|
||||
const input = {
|
||||
user: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'user.name', value: 'John' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'user.age', value: 30 }));
|
||||
});
|
||||
|
||||
test('flattens deeply nested object', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('level1.level2.level3.value');
|
||||
expect(result[0].value).toBe('deep');
|
||||
});
|
||||
|
||||
test('treats arrays as leaf values (converts to JSON string)', () => {
|
||||
const input = {
|
||||
tags: ['a', 'b', 'c'],
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('tags');
|
||||
expect(result[0].value).toBe('["a","b","c"]');
|
||||
expect(result[0].dataType).toBe(DATA_TYPE.array);
|
||||
});
|
||||
|
||||
test('treats date strings as leaf values', () => {
|
||||
const input = {
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('createdAt');
|
||||
expect(result[0].dataType).toBe(DATA_TYPE.date);
|
||||
});
|
||||
|
||||
test('handles mixed nested and flat structure', () => {
|
||||
const input = {
|
||||
id: '123',
|
||||
metadata: {
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
source: 'web',
|
||||
details: {
|
||||
referrer: 'google',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
const keys = result.map(r => r.key);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(keys).toContain('id');
|
||||
expect(keys).toContain('metadata.timestamp');
|
||||
expect(keys).toContain('metadata.source');
|
||||
expect(keys).toContain('metadata.details.referrer');
|
||||
});
|
||||
|
||||
test('returns empty array for empty object', () => {
|
||||
expect(flattenJSON({})).toEqual([]);
|
||||
});
|
||||
|
||||
test('converts boolean values to strings', () => {
|
||||
const input = {
|
||||
isActive: true,
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'isActive', value: 'true', dataType: DATA_TYPE.boolean }),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'isAdmin', value: 'false', dataType: DATA_TYPE.boolean }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
-32
@@ -1,27 +1,26 @@
|
||||
import { DATA_TYPE, DATETIME_REGEX } from './constants';
|
||||
import type { DynamicDataType } from './types';
|
||||
|
||||
export function flattenJSON(
|
||||
eventData: Record<string, any>,
|
||||
keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [],
|
||||
parentKey = '',
|
||||
): { key: string; value: any; dataType: DynamicDataType }[] {
|
||||
return Object.keys(eventData).reduce(
|
||||
(acc, key) => {
|
||||
const value = eventData[key];
|
||||
const type = typeof eventData[key];
|
||||
export interface KeyValueData {
|
||||
key: string;
|
||||
value: any;
|
||||
dataType: DynamicDataType;
|
||||
}
|
||||
|
||||
// nested object
|
||||
if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
|
||||
flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
|
||||
} else {
|
||||
createKey(getKeyName(key, parentKey), value, acc);
|
||||
export function flattenJSON(eventData: Record<string, any>): KeyValueData[] {
|
||||
function flatten(obj: Record<string, any>, parentKey: string): KeyValueData[] {
|
||||
return Object.entries(obj).flatMap(([key, value]) => {
|
||||
const fullKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
|
||||
return flatten(value, fullKey);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ keyValues, parentKey },
|
||||
).keyValues;
|
||||
return [createKeyValue(fullKey, value)];
|
||||
});
|
||||
}
|
||||
|
||||
return flatten(eventData, '');
|
||||
}
|
||||
|
||||
export function isValidDateValue(value: string) {
|
||||
@@ -50,10 +49,10 @@ export function getStringValue(value: string, dataType: number) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) {
|
||||
export function createKeyValue(key: string, value: any): KeyValueData {
|
||||
const type = getDataType(value);
|
||||
|
||||
let dataType = null;
|
||||
let dataType: DynamicDataType;
|
||||
let processedValue = value;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
@@ -64,29 +63,21 @@ function createKey(key: string, value: string, acc: { keyValues: any[]; parentKe
|
||||
break;
|
||||
case 'boolean':
|
||||
dataType = DATA_TYPE.boolean;
|
||||
value = value ? 'true' : 'false';
|
||||
processedValue = value ? 'true' : 'false';
|
||||
break;
|
||||
case 'date':
|
||||
dataType = DATA_TYPE.date;
|
||||
break;
|
||||
case 'object':
|
||||
dataType = DATA_TYPE.array;
|
||||
value = JSON.stringify(value);
|
||||
processedValue = JSON.stringify(value);
|
||||
break;
|
||||
default:
|
||||
dataType = DATA_TYPE.string;
|
||||
break;
|
||||
}
|
||||
|
||||
acc.keyValues.push({ key, value, dataType });
|
||||
}
|
||||
|
||||
function getKeyName(key: string, parentKey: string) {
|
||||
if (!parentKey) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return `${parentKey}.${key}`;
|
||||
return { key, value: processedValue, dataType };
|
||||
}
|
||||
|
||||
export function objectToArray(obj: object) {
|
||||
|
||||
@@ -82,6 +82,16 @@ export const pagingParams = {
|
||||
|
||||
export const sortingParams = {
|
||||
orderBy: z.string().optional(),
|
||||
sortDescending: z
|
||||
.enum(['true', 'false'])
|
||||
.optional()
|
||||
.transform(value => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value === 'true';
|
||||
}),
|
||||
};
|
||||
|
||||
export const userRoleParam = z.enum(['admin', 'user', 'view-only']);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QueryFilters } from './types';
|
||||
|
||||
export function sanitizeSortFilters<const T extends readonly string[]>(
|
||||
filters: QueryFilters = {},
|
||||
allowedFields: T,
|
||||
defaults: Partial<Pick<QueryFilters, 'orderBy' | 'sortDescending'>> = {},
|
||||
): QueryFilters {
|
||||
const { orderBy, sortDescending, ...rest } = filters;
|
||||
const fallbackOrderBy = defaults.orderBy;
|
||||
const fallbackSortDescending = defaults.sortDescending;
|
||||
const isAllowed = orderBy ? allowedFields.includes(orderBy as T[number]) : false;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(isAllowed
|
||||
? {
|
||||
orderBy,
|
||||
sortDescending,
|
||||
}
|
||||
: {
|
||||
...(fallbackOrderBy && { orderBy: fallbackOrderBy }),
|
||||
...(fallbackSortDescending !== undefined && {
|
||||
sortDescending: fallbackSortDescending,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import { BOARD_TYPES } from '@/lib/boards';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const BOARD_SORT_FIELDS = ['name', 'description', 'type', 'createdAt'] as const;
|
||||
|
||||
export async function findBoard(criteria: Prisma.BoardFindUniqueArgs) {
|
||||
return prisma.client.board.findUnique(criteria);
|
||||
}
|
||||
@@ -16,7 +19,8 @@ export async function getBoard(boardId: string) {
|
||||
}
|
||||
|
||||
export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: QueryFilters = {}) {
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, BOARD_SORT_FIELDS);
|
||||
const { search } = sortFilters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where: Prisma.BoardWhereInput = {
|
||||
@@ -24,7 +28,7 @@ export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: Que
|
||||
...getSearchParameters(search, [{ name: 'contains' }, { description: 'contains' }]),
|
||||
};
|
||||
|
||||
return pagedQuery('board', { ...criteria, where }, filters);
|
||||
return pagedQuery('board', { ...criteria, where }, sortFilters);
|
||||
}
|
||||
|
||||
export async function getUserBoards(userId: string, filters?: QueryFilters) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const LINK_SORT_FIELDS = ['name', 'slug', 'url', 'createdAt'] as const;
|
||||
|
||||
export async function findLink(criteria: Prisma.LinkFindUniqueArgs) {
|
||||
return prisma.client.link.findUnique(criteria);
|
||||
}
|
||||
@@ -15,7 +18,8 @@ export async function getLink(linkId: string) {
|
||||
}
|
||||
|
||||
export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: QueryFilters = {}) {
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, LINK_SORT_FIELDS);
|
||||
const { search } = sortFilters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where: Prisma.LinkWhereInput = {
|
||||
@@ -27,7 +31,7 @@ export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: Query
|
||||
]),
|
||||
};
|
||||
|
||||
return pagedQuery('link', { ...criteria, where }, filters);
|
||||
return pagedQuery('link', { ...criteria, where }, sortFilters);
|
||||
}
|
||||
|
||||
export async function getUserLinks(userId: string, filters?: QueryFilters) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const PIXEL_SORT_FIELDS = ['name', 'slug', 'createdAt'] as const;
|
||||
|
||||
export async function findPixel(criteria: Prisma.PixelFindUniqueArgs) {
|
||||
return prisma.client.pixel.findUnique(criteria);
|
||||
}
|
||||
@@ -15,14 +18,15 @@ export async function getPixel(pixelId: string) {
|
||||
}
|
||||
|
||||
export async function getPixels(criteria: Prisma.PixelFindManyArgs, filters: QueryFilters = {}) {
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, PIXEL_SORT_FIELDS);
|
||||
const { search } = sortFilters;
|
||||
|
||||
const where: Prisma.PixelWhereInput = {
|
||||
...criteria.where,
|
||||
...prisma.getSearchParameters(search, [{ name: 'contains' }, { slug: 'contains' }]),
|
||||
};
|
||||
|
||||
return prisma.pagedQuery('pixel', { ...criteria, where }, filters);
|
||||
return prisma.pagedQuery('pixel', { ...criteria, where }, sortFilters);
|
||||
}
|
||||
|
||||
export async function getUserPixels(userId: string, filters?: QueryFilters) {
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Prisma, type Team } from '@/generated/prisma/client';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { PageResult, QueryFilters } from '@/lib/types';
|
||||
|
||||
import TeamFindManyArgs = Prisma.TeamFindManyArgs;
|
||||
|
||||
const TEAM_SORT_FIELDS = ['name', 'createdAt'] as const;
|
||||
|
||||
export async function findTeam(criteria: Prisma.TeamFindUniqueArgs): Promise<Team> {
|
||||
return prisma.client.team.findUnique(criteria);
|
||||
}
|
||||
@@ -29,7 +32,8 @@ export async function getTeams(
|
||||
filters: QueryFilters,
|
||||
): Promise<PageResult<Team[]>> {
|
||||
const { getSearchParameters } = prisma;
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, TEAM_SORT_FIELDS);
|
||||
const { search } = sortFilters;
|
||||
|
||||
const where: Prisma.TeamWhereInput = {
|
||||
...criteria.where,
|
||||
@@ -42,7 +46,7 @@ export async function getTeams(
|
||||
...criteria,
|
||||
where,
|
||||
},
|
||||
filters,
|
||||
sortFilters,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Prisma } from '@/generated/prisma/client';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { QueryFilters, Role } from '@/lib/types';
|
||||
|
||||
import UserFindManyArgs = Prisma.UserFindManyArgs;
|
||||
|
||||
const USER_SORT_FIELDS = ['username', 'role', 'createdAt'] as const;
|
||||
|
||||
export interface GetUserOptions {
|
||||
includePassword?: boolean;
|
||||
showDeleted?: boolean;
|
||||
@@ -46,7 +49,11 @@ export async function getUserByUsername(username: string, options: GetUserOption
|
||||
}
|
||||
|
||||
export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters = {}) {
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, USER_SORT_FIELDS, {
|
||||
orderBy: 'createdAt',
|
||||
sortDescending: true,
|
||||
});
|
||||
const { search } = sortFilters;
|
||||
|
||||
const where: Prisma.UserWhereInput = {
|
||||
...criteria.where,
|
||||
@@ -60,11 +67,7 @@ export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters
|
||||
...criteria,
|
||||
where,
|
||||
},
|
||||
{
|
||||
orderBy: 'createdAt',
|
||||
sortDescending: true,
|
||||
...filters,
|
||||
},
|
||||
sortFilters,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { Prisma, Website } from '@/generated/prisma/client';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
import { sanitizeSortFilters } from '@/lib/sort';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const WEBSITE_SORT_FIELDS = ['name', 'domain', 'createdAt'] as const;
|
||||
|
||||
export async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs) {
|
||||
return prisma.client.website.findUnique(criteria);
|
||||
}
|
||||
@@ -23,7 +26,8 @@ export async function getWebsite(websiteId: string) {
|
||||
}
|
||||
|
||||
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
|
||||
const { search } = filters;
|
||||
const sortFilters = sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS);
|
||||
const { search } = sortFilters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where: Prisma.WebsiteWhereInput = {
|
||||
@@ -37,7 +41,7 @@ export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters:
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
const websites = await pagedQuery('website', { ...criteria, where }, filters);
|
||||
const websites = await pagedQuery('website', { ...criteria, where }, sortFilters);
|
||||
|
||||
return attachShareIdToWebsites(websites);
|
||||
}
|
||||
@@ -62,10 +66,7 @@ export async function getAllUserWebsitesIncludingTeamAccess(userId: string, filt
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
orderBy: 'name',
|
||||
...filters,
|
||||
},
|
||||
sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,10 +85,7 @@ export async function getUserWebsites(userId: string, filters?: QueryFilters) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
orderBy: 'name',
|
||||
...filters,
|
||||
},
|
||||
sanitizeSortFilters(filters, WEBSITE_SORT_FIELDS, { orderBy: 'name' }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,10 +96,6 @@ async function clickhouseQuery(websiteId: string, column: string, filters: Query
|
||||
excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
if (decodeURIComponent(search).includes(',')) {
|
||||
searchQuery = `AND (${decodeURIComponent(search)
|
||||
|
||||
Reference in New Issue
Block a user