feat: add user groups (#376)

* feat: add user groups

* wip: add unit tests

* wip: add more tests and normalized name for creation and update

* test: add unit tests for group router

* fix: type issues, missing mysql schema, rename column creator_id to owner_id

* fix: lint and format issues

* fix: deepsource issues

* fix: forgot to add log message

* fix: build not working

* chore: address pull request feedback

* feat: add mysql migration and fix merge conflicts

* fix: format issue and test issue
This commit is contained in:
Meier Lukas
2024-04-29 21:46:30 +02:00
committed by GitHub
parent 621f6c81ae
commit 036925bf78
50 changed files with 3333 additions and 132 deletions

View File

@@ -1,5 +1,6 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { groupRouter } from "./router/group";
import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
group: groupRouter,
invite: inviteRouter,
integration: integrationRouter,
board: boardRouter,

View File

@@ -0,0 +1,232 @@
import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
} from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(validation.group.paginated)
.query(async ({ input, ctx }) => {
const whereQuery = input.search
? like(groups.name, `%${input.search.trim()}%`)
: undefined;
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount[0]!.count,
};
}),
getById: protectedProcedure
.input(validation.group.byId)
.query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map(
(permission) => permission.permission,
),
};
}),
createGroup: protectedProcedure
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrow(ctx.db, normalizedName);
const id = createId();
await ctx.db.insert(groups).values({
id,
name: normalizedName,
ownerId: ctx.session.user.id,
});
return id;
}),
updateGroup: protectedProcedure
.input(validation.group.update)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrow(ctx.db, normalizedName, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName,
})
.where(eq(groups.id, input.id));
}),
savePermissions: protectedProcedure
.input(validation.group.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupPermissions)
.where(eq(groupPermissions.groupId, input.groupId));
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}),
transferOwnership: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure
.input(validation.group.byId)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupMembers)
.where(
and(
eq(groupMembers.groupId, input.groupId),
eq(groupMembers.userId, input.userId),
),
);
}),
});
const normalizeName = (name: string) => name.trim();
const checkSimilarNameAndThrow = async (
db: Database,
name: string,
ignoreId?: string,
) => {
const similar = await db.query.groups.findFirst({
where: and(
like(groups.name, `${name}`),
not(eq(groups.id, ignoreId ?? "")),
),
});
if (similar) {
throw new TRPCError({
code: "CONFLICT",
message: "Found group with similar name",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
};

View File

@@ -0,0 +1,664 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId, eq } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { groupRouter } from "../group";
const defaultOwnerId = createId();
const defaultSession = {
user: {
id: defaultOwnerId,
},
expires: new Date().toISOString(),
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
return { ...mod, auth: () => ({}) as Session };
});
describe("paginated should return a list of groups with pagination", () => {
test.each([
[1, 3],
[2, 2],
])(
"with 5 groups in database and pageSize set to 3 on page %s it should return %s groups",
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
})),
);
// Act
const result = await caller.getPaginated({
page,
pageSize: 3,
});
// Assert
expect(result.items.length).toBe(expectedCount);
},
);
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
})),
);
// Act
const result = await caller.getPaginated({
pageSize: 3,
});
// Assert
expect(result.totalCount).toBe(5);
});
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const user = createDummyUser();
await db.insert(users).values(user);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
});
await db.insert(groupMembers).values({
groupId,
userId: user.id,
});
// Act
const result = await caller.getPaginated({});
// Assert
const item = result.items[0];
expect(item).toBeDefined();
expect(item?.members.length).toBe(1);
const userKeys = Object.keys(item?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
});
test.each([
[undefined, 5, "first"],
["d", 2, "second"],
["th", 3, "third"],
["fi", 2, "first"],
])(
"groups should be searchable by name with contains pattern, query %s should result in %s results",
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(),
name: key,
})),
);
// Act
const result = await caller.getPaginated({
search: query,
});
// Assert
expect(result.totalCount).toBe(expectedCount);
expect(result.items.at(0)?.name).toBe(firstKey);
},
);
});
describe("byId should return group by id including members and permissions", () => {
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const user = createDummyUser();
const groupId = "1";
await db.insert(users).values(user);
await db.insert(groups).values([
{
id: groupId,
name: "Group",
},
{
id: createId(),
name: "Another group",
},
]);
await db.insert(groupMembers).values({
userId: user.id,
groupId,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
const result = await caller.getById({
id: groupId,
});
// Assert
expect(result.id).toBe(groupId);
expect(result.members.length).toBe(1);
const userKeys = Object.keys(result?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
expect(result.permissions.length).toBe(1);
expect(result.permissions[0]).toBe("admin");
});
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: "2",
name: "Group",
});
// Act
const act = async () => await caller.getById({ id: "1" });
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
// Act
const result = await caller.createGroup({
name,
});
// Assert
const item = await db.query.groups.findFirst({
where: eq(groups.id, result),
});
expect(item).toBeDefined();
expect(item?.id).toBe(result);
expect(item?.ownerId).toBe(defaultOwnerId);
expect(item?.name).toBe(name);
});
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const longName = "a".repeat(65);
// Act
const act = async () =>
await caller.createGroup({
name: longName,
});
// Assert
await expect(act()).rejects.toThrow("too_big");
});
test.each([
["test", "Test"],
["test", "Test "],
["test", "test"],
["test", " TeSt"],
])(
"with similar name %s it should fail to create %s",
async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: similarName,
});
// Act
const act = async () => await caller.createGroup({ name: nameToCreate });
// Assert
await expect(act()).rejects.toThrow("similar name");
},
);
});
describe("update should update name with value that is no duplicate", () => {
test.each([
["first", "second ", "second"],
["first", " first", "first"],
])(
"update should update name from %s to %s normalized",
async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: initialValue,
},
{
id: createId(),
name: "Third",
},
]);
// Act
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
const value = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(value?.name).toBe(expectedValue);
},
);
test.each([
["Second ", "second"],
[" seCond", "second"],
])(
"with similar name %s it should fail to update %s",
async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Something",
},
{
id: createId(),
name: initialDuplicate,
},
]);
// Act
const act = async () =>
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
await expect(act()).rejects.toThrow("similar name");
},
);
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "something",
});
// Act
const act = () =>
caller.updateGroup({
id: createId(),
name: "something else",
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
await caller.savePermissions({
groupId,
permissions: ["integration-use-all", "board-full-access"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission)).toEqual([
"integration-use-all",
"board-full-access",
]);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-access"],
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const newUserId = createId();
await db.insert(users).values([
{
id: newUserId,
name: "New user",
},
{
id: defaultOwnerId,
name: "Old user",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
// Act
await caller.transferOwnership({
groupId,
userId: newUserId,
});
// Assert
const group = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(group?.ownerId).toBe(newUserId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.transferOwnership({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Group",
},
{
id: createId(),
name: "Another group",
},
]);
// Act
await caller.deleteGroup({
id: groupId,
});
// Assert
const dbGroups = await db.query.groups.findMany();
expect(dbGroups.length).toBe(1);
expect(dbGroups[0]?.id).not.toBe(groupId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.deleteGroup({
id: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("addMember should add member to group", () => {
test("with existing group and user it should add member", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
// Act
await caller.addMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(1);
expect(members[0]?.userId).toBe(userId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const act = async () =>
await caller.addMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("removeMember should remove member from group", () => {
test("with existing group and user it should remove member", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
await db.insert(groupMembers).values({
groupId,
userId,
});
// Act
await caller.removeMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(0);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const act = async () =>
await caller.removeMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
const createDummyUser = () => ({
id: createId(),
name: "username",
email: "user@gmail.com",
image: "example",
password: "secret",
salt: "secret",
});

View File

@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const migrationsFolder = process.argv[2] ?? "./migrations";
const migrationsFolder = process.argv[2] ?? "./migrations/sqlite";
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));

View File

@@ -1,9 +0,0 @@
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`expiration_date` integer NOT NULL,
`creator_id` text NOT NULL,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);

View File

@@ -1,20 +0,0 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1710878250235,
"tag": "0000_productive_changeling",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1712777046680,
"tag": "0001_sparkling_zaran",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,168 @@
CREATE TABLE `account` (
`userId` varchar(256) NOT NULL,
`type` text NOT NULL,
`provider` varchar(256) NOT NULL,
`providerAccountId` varchar(256) NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` int,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
CONSTRAINT `account_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
);
--> statement-breakpoint
CREATE TABLE `app` (
`id` varchar(256) NOT NULL,
`name` text NOT NULL,
`description` text,
`icon_url` text NOT NULL,
`href` text,
CONSTRAINT `app_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `boardPermission` (
`board_id` text NOT NULL,
`user_id` text NOT NULL,
`permission` text NOT NULL,
CONSTRAINT `boardPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `board` (
`id` varchar(256) NOT NULL,
`name` varchar(256) NOT NULL,
`is_public` boolean NOT NULL DEFAULT false,
`creator_id` text,
`page_title` text,
`meta_title` text,
`logo_image_url` text,
`favicon_image_url` text,
`background_image_url` text,
`background_image_attachment` text NOT NULL DEFAULT ('fixed'),
`background_image_repeat` text NOT NULL DEFAULT ('no-repeat'),
`background_image_size` text NOT NULL DEFAULT ('cover'),
`primary_color` text NOT NULL DEFAULT ('#fa5252'),
`secondary_color` text NOT NULL DEFAULT ('#fd7e14'),
`opacity` int NOT NULL DEFAULT 100,
`custom_css` text,
`column_count` int NOT NULL DEFAULT 10,
CONSTRAINT `board_id` PRIMARY KEY(`id`),
CONSTRAINT `board_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `groupMember` (
`groupId` varchar(256) NOT NULL,
`userId` varchar(256) NOT NULL,
CONSTRAINT `groupMember_groupId_userId_pk` PRIMARY KEY(`groupId`,`userId`)
);
--> statement-breakpoint
CREATE TABLE `groupPermission` (
`groupId` varchar(256) NOT NULL,
`permission` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `group` (
`id` varchar(256) NOT NULL,
`name` varchar(64) NOT NULL,
`owner_id` varchar(256),
CONSTRAINT `group_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `integration_item` (
`item_id` varchar(256) NOT NULL,
`integration_id` varchar(256) NOT NULL,
CONSTRAINT `integration_item_item_id_integration_id_pk` PRIMARY KEY(`item_id`,`integration_id`)
);
--> statement-breakpoint
CREATE TABLE `integrationSecret` (
`kind` varchar(16) NOT NULL,
`value` text NOT NULL,
`updated_at` timestamp NOT NULL,
`integration_id` varchar(256) NOT NULL,
CONSTRAINT `integrationSecret_integration_id_kind_pk` PRIMARY KEY(`integration_id`,`kind`)
);
--> statement-breakpoint
CREATE TABLE `integration` (
`id` varchar(256) NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`kind` varchar(128) NOT NULL,
CONSTRAINT `integration_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `invite` (
`id` varchar(256) NOT NULL,
`token` varchar(512) NOT NULL,
`expiration_date` timestamp NOT NULL,
`creator_id` varchar(256) NOT NULL,
CONSTRAINT `invite_id` PRIMARY KEY(`id`),
CONSTRAINT `invite_token_unique` UNIQUE(`token`)
);
--> statement-breakpoint
CREATE TABLE `item` (
`id` varchar(256) NOT NULL,
`section_id` varchar(256) NOT NULL,
`kind` text NOT NULL,
`x_offset` int NOT NULL,
`y_offset` int NOT NULL,
`width` int NOT NULL,
`height` int NOT NULL,
`options` text NOT NULL DEFAULT ('{"json": {}}'),
CONSTRAINT `item_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `section` (
`id` varchar(256) NOT NULL,
`board_id` varchar(256) NOT NULL,
`kind` text NOT NULL,
`position` int NOT NULL,
`name` text,
CONSTRAINT `section_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `session` (
`sessionToken` varchar(512) NOT NULL,
`userId` varchar(256) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `session_sessionToken` PRIMARY KEY(`sessionToken`)
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` varchar(256) NOT NULL,
`name` text,
`email` text,
`emailVerified` timestamp,
`image` text,
`password` text,
`salt` text,
CONSTRAINT `user_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `verificationToken` (
`identifier` varchar(256) NOT NULL,
`token` varchar(512) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `verificationToken_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
);
--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);--> statement-breakpoint
ALTER TABLE `account` ADD CONSTRAINT `account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `board` ADD CONSTRAINT `board_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupPermission` ADD CONSTRAINT `groupPermission_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_owner_id_user_id_fk` FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integration_item` ADD CONSTRAINT `integration_item_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integration_item` ADD CONSTRAINT `integration_item_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integrationSecret` ADD CONSTRAINT `integrationSecret_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `invite` ADD CONSTRAINT `invite_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `item` ADD CONSTRAINT `item_section_id_section_id_fk` FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `section` ADD CONSTRAINT `section_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `session` ADD CONSTRAINT `session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;

View File

@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "7c2291ee-febd-4b90-994c-85e6ef27102d",
"dialect": "mysql",
"id": "d0a05e9e-107f-4bed-ac54-a4a41369f0da",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -9,7 +9,7 @@
"columns": {
"userId": {
"name": "userId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -23,14 +23,14 @@
},
"provider": {
"name": "provider",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -51,7 +51,7 @@
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
@@ -105,8 +105,8 @@
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": ["provider", "providerAccountId"],
"name": "account_provider_providerAccountId_pk"
"name": "account_provider_providerAccountId_pk",
"columns": ["provider", "providerAccountId"]
}
},
"uniqueConstraints": {}
@@ -116,8 +116,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -152,7 +152,12 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"app_id": {
"name": "app_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"boardPermission": {
@@ -203,8 +208,8 @@
},
"compositePrimaryKeys": {
"boardPermission_board_id_user_id_permission_pk": {
"columns": ["board_id", "permission", "user_id"],
"name": "boardPermission_board_id_user_id_permission_pk"
"name": "boardPermission_board_id_user_id_permission_pk",
"columns": ["board_id", "user_id", "permission"]
}
},
"uniqueConstraints": {}
@@ -214,21 +219,21 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
@@ -282,7 +287,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed'"
"default": "('fixed')"
},
"background_image_repeat": {
"name": "background_image_repeat",
@@ -290,7 +295,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'no-repeat'"
"default": "('no-repeat')"
},
"background_image_size": {
"name": "background_image_size",
@@ -298,7 +303,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'cover'"
"default": "('cover')"
},
"primary_color": {
"name": "primary_color",
@@ -306,7 +311,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fa5252'"
"default": "('#fa5252')"
},
"secondary_color": {
"name": "secondary_color",
@@ -314,11 +319,11 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fd7e14'"
"default": "('#fd7e14')"
},
"opacity": {
"name": "opacity",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
@@ -333,20 +338,14 @@
},
"column_count": {
"name": "column_count",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 10
}
},
"indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"board_creator_id_user_id_fk": {
"name": "board_creator_id_user_id_fk",
@@ -358,22 +357,157 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"board_id": {
"name": "board_id",
"columns": ["id"]
}
},
"uniqueConstraints": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"]
}
}
},
"groupMember": {
"name": "groupMember",
"columns": {
"groupId": {
"name": "groupId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupMember_groupId_group_id_fk": {
"name": "groupMember_groupId_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"groupMember_userId_user_id_fk": {
"name": "groupMember_userId_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"groupMember_groupId_userId_pk": {
"name": "groupMember_groupId_userId_pk",
"columns": ["groupId", "userId"]
}
},
"uniqueConstraints": {}
},
"groupPermission": {
"name": "groupPermission",
"columns": {
"groupId": {
"name": "groupId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupPermission_groupId_group_id_fk": {
"name": "groupPermission_groupId_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"group_owner_id_user_id_fk": {
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"group_id": {
"name": "group_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"integration_item": {
"name": "integration_item",
"columns": {
"item_id": {
"name": "item_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -402,8 +536,8 @@
},
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"columns": ["integration_id", "item_id"],
"name": "integration_item_item_id_integration_id_pk"
"name": "integration_item_item_id_integration_id_pk",
"columns": ["item_id", "integration_id"]
}
},
"uniqueConstraints": {}
@@ -413,7 +547,7 @@
"columns": {
"kind": {
"name": "kind",
"type": "text",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -427,14 +561,14 @@
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -465,8 +599,8 @@
},
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"columns": ["integration_id", "kind"],
"name": "integrationSecret_integration_id_kind_pk"
"name": "integrationSecret_integration_id_kind_pk",
"columns": ["integration_id", "kind"]
}
},
"uniqueConstraints": {}
@@ -476,8 +610,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -497,7 +631,7 @@
},
"kind": {
"name": "kind",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -511,22 +645,84 @@
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"integration_id": {
"name": "integration_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"invite": {
"name": "invite",
"columns": {
"id": {
"name": "id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiration_date": {
"name": "expiration_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_id": {
"name": "creator_id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"invite_creator_id_user_id_fk": {
"name": "invite_creator_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"invite_id": {
"name": "invite_id",
"columns": ["id"]
}
},
"uniqueConstraints": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": ["token"]
}
}
},
"item": {
"name": "item",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"section_id": {
"name": "section_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -540,28 +736,28 @@
},
"x_offset": {
"name": "x_offset",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"y_offset": {
"name": "y_offset",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -572,7 +768,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
"default": "('{\"json\": {}}')"
}
},
"indexes": {},
@@ -587,7 +783,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"item_id": {
"name": "item_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"section": {
@@ -595,14 +796,14 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"board_id": {
"name": "board_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -616,7 +817,7 @@
},
"position": {
"name": "position",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -641,7 +842,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"section_id": {
"name": "section_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"session": {
@@ -649,21 +855,21 @@
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -687,7 +893,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"session_sessionToken": {
"name": "session_sessionToken",
"columns": ["sessionToken"]
}
},
"uniqueConstraints": {}
},
"user": {
@@ -695,8 +906,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -716,7 +927,7 @@
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
@@ -745,7 +956,12 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"user_id": {
"name": "user_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"verificationToken": {
@@ -753,21 +969,21 @@
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -777,14 +993,14 @@
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": ["identifier", "token"],
"name": "verificationToken_identifier_token_pk"
"name": "verificationToken_identifier_token_pk",
"columns": ["identifier", "token"]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1714414260766,
"tag": "0000_chubby_darkhawk",
"breakpoints": true
}
]
}

View File

@@ -52,6 +52,27 @@ CREATE TABLE `board` (
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `groupMember` (
`groupId` text NOT NULL,
`userId` text NOT NULL,
PRIMARY KEY(`groupId`, `userId`),
FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `groupPermission` (
`groupId` text NOT NULL,
`permission` text NOT NULL,
FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `group` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_id` text,
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `integration_item` (
`item_id` text NOT NULL,
`integration_id` text NOT NULL,
@@ -76,6 +97,14 @@ CREATE TABLE `integration` (
`kind` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`expiration_date` integer NOT NULL,
`creator_id` text NOT NULL,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `item` (
`id` text PRIMARY KEY NOT NULL,
`section_id` text NOT NULL,
@@ -126,4 +155,5 @@ CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakp
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);

View File

@@ -1,8 +1,8 @@
{
"version": "5",
"dialect": "sqlite",
"id": "c0a91279-dffa-4567-8cd2-d9d2d1a2e77c",
"prevId": "7c2291ee-febd-4b90-994c-85e6ef27102d",
"id": "e3ff4a97-d357-4a64-989b-78668b36c82d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
"name": "account",
@@ -361,6 +361,126 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"groupMember": {
"name": "groupMember",
"columns": {
"groupId": {
"name": "groupId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupMember_groupId_group_id_fk": {
"name": "groupMember_groupId_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"groupMember_userId_user_id_fk": {
"name": "groupMember_userId_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"groupMember_groupId_userId_pk": {
"columns": ["groupId", "userId"],
"name": "groupMember_groupId_userId_pk"
}
},
"uniqueConstraints": {}
},
"groupPermission": {
"name": "groupPermission",
"columns": {
"groupId": {
"name": "groupId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupPermission_groupId_group_id_fk": {
"name": "groupPermission_groupId_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"group_owner_id_user_id_fk": {
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"integration_item": {
"name": "integration_item",
"columns": {

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1714414359385,
"tag": "0000_abnormal_kree",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,17 @@
import * as dotenv from "dotenv";
import type { Config } from "drizzle-kit";
dotenv.config({ path: "../../.env" });
export default {
schema: "./schema",
driver: "mysql2",
dbCredentials: {
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
port: parseInt(process.env.DB_PORT!),
},
out: "./migrations/mysql",
} satisfies Config;

View File

@@ -17,8 +17,9 @@
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"migration:generate": "drizzle-kit generate:sqlite",
"migration:sqlite:generate": "drizzle-kit generate:sqlite --config ./sqlite.config.ts",
"migration:run": "tsx ./migrate.ts",
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
"push": "drizzle-kit push:sqlite",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"

View File

@@ -16,6 +16,7 @@ import type {
BackgroundImageRepeat,
BackgroundImageSize,
BoardPermission,
GroupPermissionKey,
IntegrationKind,
IntegrationSecretKind,
SectionKind,
@@ -92,6 +93,38 @@ export const verificationTokens = mysqlTable(
}),
);
export const groupMembers = mysqlTable(
"groupMember",
{
groupId: varchar("groupId", { length: 256 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: varchar("userId", { length: 256 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(groupMember) => ({
compoundKey: primaryKey({
columns: [groupMember.groupId, groupMember.userId],
}),
}),
);
export const groups = mysqlTable("group", {
id: varchar("id", { length: 256 }).notNull().primaryKey(),
name: varchar("name", { length: 64 }).notNull(),
ownerId: varchar("owner_id", { length: 256 }).references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = mysqlTable("groupPermission", {
groupId: varchar("groupId", { length: 256 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
});
export const invites = mysqlTable("invite", {
id: varchar("id", { length: 256 }).notNull().primaryKey(),
token: varchar("token", { length: 512 }).notNull().unique(),
@@ -245,6 +278,8 @@ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
}));
@@ -262,6 +297,36 @@ export const sessionRelations = relations(sessions, ({ one }) => ({
}),
}));
export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
}));
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
references: [users.id],
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
({ one }) => ({

View File

@@ -20,6 +20,7 @@ import type {
BackgroundImageRepeat,
BackgroundImageSize,
BoardPermission,
GroupPermissionKey,
IntegrationKind,
IntegrationSecretKind,
SectionKind,
@@ -89,6 +90,38 @@ export const verificationTokens = sqliteTable(
}),
);
export const groupMembers = sqliteTable(
"groupMember",
{
groupId: text("groupId")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(groupMember) => ({
compoundKey: primaryKey({
columns: [groupMember.groupId, groupMember.userId],
}),
}),
);
export const groups = sqliteTable("group", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
ownerId: text("owner_id").references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = sqliteTable("groupPermission", {
groupId: text("groupId")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
});
export const invites = sqliteTable("invite", {
id: text("id").notNull().primaryKey(),
token: text("token").notNull().unique(),
@@ -242,6 +275,8 @@ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
}));
@@ -259,6 +294,36 @@ export const sessionRelations = relations(sessions, ({ one }) => ({
}),
}));
export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
}));
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
references: [users.id],
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
({ one }) => ({

View File

@@ -7,5 +7,5 @@ export default {
schema: "./schema",
driver: "better-sqlite",
dbCredentials: { url: process.env.DB_URL! },
out: "./migrations",
out: "./migrations/sqlite",
} satisfies Config;

View File

@@ -8,7 +8,7 @@ export const createDb = () => {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema });
migrate(db, {
migrationsFolder: "./packages/db/migrations",
migrationsFolder: "./packages/db/migrations/sqlite",
});
return db;
};

View File

@@ -1,3 +1,69 @@
import { objectKeys } from "@homarr/common";
export const boardPermissions = ["board-view", "board-change"] as const;
export const groupPermissions = {
board: ["create", "view-all", "modify-all", "full-access"],
integration: ["create", "use-all", "interact-all", "full-access"],
admin: true,
} as const;
/**
* In the following object is described how the permissions are related to each other.
* For example everybody with the permission "board-modify-all" also has the permission "board-view-all".
* Or admin has all permissions (board-full-access and integration-full-access which will resolve in an array of every permission).
*/
const groupPermissionParents = {
"board-modify-all": ["board-view-all"],
"board-full-access": ["board-modify-all", "board-create"],
"integration-interact-all": ["integration-use-all"],
"integration-full-access": ["integration-interact-all", "integration-create"],
admin: ["board-full-access", "integration-full-access"],
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
const getPermissionsInner = (
permissionSet: Set<GroupPermissionKey>,
permissions: GroupPermissionKey[],
) => {
permissions.forEach((permission) => {
const children =
groupPermissionParents[permission as keyof typeof groupPermissionParents];
if (children) {
getPermissionsInner(permissionSet, children);
}
permissionSet.add(permission);
});
};
export const getPermissionsWithChildren = (
permissions: GroupPermissionKey[],
) => {
const permissionSet = new Set<GroupPermissionKey>();
getPermissionsInner(permissionSet, permissions);
return Array.from(permissionSet);
};
type GroupPermissions = typeof groupPermissions;
export type GroupPermissionKey = {
[key in keyof GroupPermissions]: GroupPermissions[key] extends readonly string[]
? `${key}-${GroupPermissions[key][number]}`
: key;
}[keyof GroupPermissions];
export const groupPermissionKeys = objectKeys(groupPermissions).reduce(
(acc, key) => {
const item = groupPermissions[key];
if (typeof item !== "boolean") {
acc.push(
...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey),
);
} else {
acc.push(key as GroupPermissionKey);
}
return acc;
},
[] as GroupPermissionKey[],
);
export type BoardPermission = (typeof boardPermissions)[number];

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import type { ButtonProps, GroupProps } from "@mantine/core";
import { Box, Button, Group } from "@mantine/core";
@@ -33,6 +33,7 @@ export interface ConfirmModalProps {
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
({ actions, innerProps }) => {
const [loading, setLoading] = useState(false);
const t = useI18n();
const {
children,
@@ -65,10 +66,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
typeof confirmProps?.onClick === "function" &&
confirmProps?.onClick(event);
typeof onConfirm === "function" && (await onConfirm());
closeOnConfirm && actions.closeModal();
setLoading(false);
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
);
@@ -82,7 +85,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
</Button>
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
<Button
{...confirmProps}
onClick={handleConfirm}
color="red.9"
loading={loading}
>
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
</Button>
</Group>

View File

@@ -3,11 +3,11 @@
import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import en from "./lang/en";
import enTranslation from "./lang/en";
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: en,
fallbackLocale: enTranslation,
},
);

View File

@@ -29,6 +29,150 @@ export default {
action: {
login: "Login",
create: "Create user",
select: {
label: "Select user",
notFound: "No user found",
},
transfer: {
label: "Select new owner",
},
},
},
group: {
title: "Groups",
name: "Group",
search: "Find a group",
field: {
name: "Name",
members: "Members",
},
permission: {
admin: {
title: "Admin",
item: {
admin: {
label: "Administrator",
description:
"Members with this permission have full access to all features and settings",
},
},
},
board: {
title: "Boards",
item: {
create: {
label: "Create boards",
description: "Allow members to create boards",
},
"view-all": {
label: "View all boards",
description: "Allow members to view all boards",
},
"modify-all": {
label: "Modify all boards",
description:
"Allow members to modify all boards (Does not include access control and danger zone)",
},
"full-access": {
label: "Full board access",
description:
"Allow members to view, modify, and delete all boards (Including access control and danger zone)",
},
},
},
integration: {
title: "Integrations",
item: {
create: {
label: "Create integrations",
description: "Allow members to create integrations",
},
"use-all": {
label: "Use all integrations",
description:
"Allows members to add any integrations to their boards",
},
"interact-all": {
label: "Interact with any integration",
description: "Allow members to interact with any integration",
},
"full-access": {
label: "Full integration access",
description:
"Allow members to manage, use and interact with any integration",
},
},
},
},
action: {
create: {
label: "New group",
notification: {
success: {
message: "The app was successfully created",
},
error: {
message: "The app could not be created",
},
},
},
transfer: {
label: "Transfer ownership",
description: "Transfer ownership of this group to another user.",
confirm:
"Are you sure you want to transfer ownership for the group {name} to {username}?",
notification: {
success: {
message: "Transfered group {group} successfully to {user}",
},
error: {
message: "Unable to transfer ownership",
},
},
},
addMember: {
label: "Add member",
},
removeMember: {
label: "Remove member",
confirm: "Are you sure you want to remove {user} from this group?",
},
delete: {
label: "Delete group",
description:
"Once you delete a group, there is no going back. Please be certain.",
confirm: "Are you sure you want to delete the group {name}?",
notification: {
success: {
message: "Deleted group {name} successfully",
},
error: {
message: "Unable to delete group {name}",
},
},
},
changePermissions: {
notification: {
success: {
title: "Permissions saved",
message: "Permissions have been saved successfully",
},
error: {
title: "Permissions not saved",
message: "Permissions have not been saved",
},
},
},
update: {
notification: {
success: {
message: "The group {name} was saved successfully",
},
error: {
message: "Unable to save group {name}",
},
},
},
},
},
app: {
@@ -204,11 +348,31 @@ export default {
save: "Save",
saveChanges: "Save changes",
cancel: "Cancel",
discard: "Discard",
confirm: "Confirm",
continue: "Continue",
previous: "Previous",
next: "Next",
checkoutDocs: "Check out the documentation",
},
notification: {
create: {
success: "Creation successful",
error: "Creation failed",
},
delete: {
success: "Deletion successful",
error: "Deletion failed",
},
update: {
success: "Changes applied successfully",
error: "Unable to apply changes",
},
transfer: {
success: "Transfer successful",
error: "Transfer failed",
},
},
multiSelect: {
placeholder: "Pick one or more values",
},
@@ -708,8 +872,6 @@ export default {
permission: {
userSelect: {
title: "Add user permission",
label: "Select user",
notFound: "No user found",
},
field: {
user: {
@@ -803,6 +965,7 @@ export default {
items: {
manage: "Manage",
invites: "Invites",
groups: "Groups",
},
},
tools: {
@@ -958,6 +1121,26 @@ export default {
},
},
},
group: {
back: "Back to groups",
setting: {
general: {
title: "General",
dangerZone: "Danger zone",
},
members: {
title: "Members",
search: "Find a member",
notFound: "No members found",
},
permissions: {
title: "Permissions",
form: {
unsavedChanges: "You have unsaved changes!",
},
},
},
},
about: {
version: "Version {version}",
text: "Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these people, Homarr has been a growing project since 2021. Our team is working completely remote from many different countries on Homarr in their leisure time for no compensation.",

View File

@@ -1,11 +1,11 @@
import { createI18nServer } from "next-international/server";
import { languageMapping } from "./lang";
import en from "./lang/en";
import enTranslation from "./lang/en";
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
languageMapping(),
{
fallbackLocale: en,
fallbackLocale: enTranslation,
},
);

View File

@@ -28,6 +28,9 @@
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"

View File

@@ -1,3 +1,7 @@
export * from "./count-badge";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { TablePagination } from "./table-pagination";
export { SearchInput } from "./search-input";

View File

@@ -0,0 +1,59 @@
"use client";
import type { ChangeEvent } from "react";
import { useCallback, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Loader, TextInput } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
interface SearchInputProps {
defaultValue?: string;
placeholder: string;
}
export const SearchInput = ({
placeholder,
defaultValue,
}: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const handleSearchDebounced = useDebouncedCallback((value: string) => {
const params = new URLSearchParams(searchParams);
params.set("search", value.toString());
if (params.has("page")) params.set("page", "1"); // Reset page to 1
replace(`${pathName}?${params.toString()}`);
setLoading(false);
}, 250);
const handleSearch = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setLoading(true);
handleSearchDebounced(event.currentTarget.value);
},
[setLoading, handleSearchDebounced],
);
return (
<TextInput
leftSection={<LeftSection loading={loading} />}
defaultValue={defaultValue}
onChange={handleSearch}
placeholder={placeholder}
/>
);
};
interface LeftSectionProps {
loading: boolean;
}
const LeftSection = ({ loading }: LeftSectionProps) => {
if (loading) {
return <Loader size="xs" />;
}
return <IconSearch size={20} stroke={1.5} />;
};

View File

@@ -0,0 +1,80 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { PaginationProps } from "@mantine/core";
import { Pagination } from "@mantine/core";
interface TablePaginationProps {
total: number;
}
export const TablePagination = ({ total }: TablePaginationProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const current = Number(searchParams.get("page")) || 1;
const getItemProps = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
return {
component: Link,
href: `?${params.toString()}`,
};
},
[searchParams],
);
const getControlProps = useCallback(
(control: ControlType) => {
return getItemProps(calculatePageFor(control, current, total));
},
[current],
);
const handleChange = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
replace(`${pathName}?${params.toString()}`);
},
[pathName, searchParams],
);
return (
<Pagination
total={total}
getItemProps={getItemProps}
getControlProps={getControlProps}
onChange={handleChange}
/>
);
};
type ControlType = Parameters<
Exclude<PaginationProps["getControlProps"], undefined>
>[0];
const calculatePageFor = (
type: ControlType,
current: number,
total: number,
) => {
switch (type) {
case "first":
return 1;
case "previous":
return Math.max(current - 1, 1);
case "next":
return current + 1;
case "last":
return total;
default:
console.error(`Unknown pagination control type: ${type as string}`);
return 1;
}
};

View File

@@ -0,0 +1,48 @@
import type { MantineSize } from "@mantine/core";
import { Avatar, AvatarGroup, Tooltip, TooltipGroup } from "@mantine/core";
import type { UserProps } from "./user-avatar";
import { UserAvatar } from "./user-avatar";
interface UserAvatarGroupProps {
size: MantineSize;
limit: number;
users: UserProps[];
}
export const UserAvatarGroup = ({
size,
limit,
users,
}: UserAvatarGroupProps) => {
return (
<TooltipGroup openDelay={300} closeDelay={300}>
<AvatarGroup>
{users.slice(0, limit).map((user) => (
<Tooltip key={user.name} label={user.name} withArrow>
<UserAvatar user={user} size={size} />
</Tooltip>
))}
<MoreUsers size={size} users={users} offset={limit} />
</AvatarGroup>
</TooltipGroup>
);
};
interface MoreUsersProps {
size: MantineSize;
users: unknown[];
offset: number;
}
const MoreUsers = ({ size, users, offset }: MoreUsersProps) => {
if (users.length <= offset) return null;
const moreAmount = users.length - offset;
return (
<Avatar size={size} radius="xl">
+{moreAmount}
</Avatar>
);
};

View File

@@ -0,0 +1,28 @@
import { Avatar } from "@mantine/core";
import type { AvatarProps, MantineSize } from "@mantine/core";
export interface UserProps {
name: string | null;
image: string | null;
}
interface UserAvatarProps {
user: UserProps | null;
size: MantineSize;
}
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
const commonProps = {
size,
color: "primaryColor",
} satisfies Partial<AvatarProps>;
if (!user?.name) return <Avatar {...commonProps} />;
if (user.image) {
return <Avatar {...commonProps} src={user.image} alt={user.name} />;
}
return (
<Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>
);
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { groupPermissionKeys } from "@homarr/definitions";
import { zodEnumFromArray } from "./enums";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
const byIdSchema = z.object({
id: z.string(),
});
const createSchema = z.object({
name: z.string().max(64),
});
const updateSchema = createSchema.merge(byIdSchema);
const savePermissionsSchema = z.object({
groupId: z.string(),
permissions: z.array(zodEnumFromArray(groupPermissionKeys)),
});
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
export const groupSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
create: createSchema,
update: updateSchema,
savePermissions: savePermissionsSchema,
groupUser: groupUserSchema,
};

View File

@@ -1,5 +1,6 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { groupSchemas } from "./group";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { userSchemas } from "./user";
@@ -7,6 +8,7 @@ import { widgetSchemas } from "./widgets";
export const validation = {
user: userSchemas,
group: groupSchemas,
integration: integrationSchemas,
board: boardSchemas,
app: appSchemas,