feat: add home board for users (#505)
* feat: add home board for users * fix: format issues * fix: deepsource issue * chore: address pull request feedback * fix: typecheck issue
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
integrationItems,
|
||||
items,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
@@ -33,14 +34,15 @@ import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent =
|
||||
await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent =
|
||||
await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
@@ -60,6 +62,11 @@ export const boardRouter = createTRPCRouter({
|
||||
)
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -98,7 +105,10 @@ export const boardRouter = createTRPCRouter({
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
});
|
||||
return dbBoards;
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
createBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
@@ -160,8 +170,31 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||
}),
|
||||
getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const boardWhere = eq(boards.name, "default");
|
||||
setHomeBoard: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"board-view",
|
||||
);
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ homeBoardId: input.id })
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const user = userId
|
||||
? await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const boardWhere = user?.homeBoardId
|
||||
? eq(boards.id, user.homeBoardId)
|
||||
: eq(boards.name, "home");
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(
|
||||
|
||||
@@ -41,6 +41,7 @@ const createRandomUserAsync = async (db: Database) => {
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
homeBoardId: null,
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
@@ -493,21 +494,21 @@ describe("deleteBoard should delete board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultBoard should return default board", () => {
|
||||
it("should return default board", async () => {
|
||||
describe("getHomeBoard should return home board", () => {
|
||||
it("should return home board", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "default");
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
|
||||
// Act
|
||||
const result = await caller.getDefaultBoard();
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "default",
|
||||
name: "home",
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
@@ -1339,7 +1340,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
input: RouterOutputs["board"]["getDefaultBoard"],
|
||||
input: RouterOutputs["board"]["getHomeBoard"],
|
||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||
) => {
|
||||
expect(input.id).toBe(props.boardId);
|
||||
|
||||
@@ -232,6 +232,7 @@ describe("editProfile shoud update user", () => {
|
||||
salt: null,
|
||||
password: null,
|
||||
image: null,
|
||||
homeBoardId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +275,7 @@ describe("editProfile shoud update user", () => {
|
||||
salt: null,
|
||||
password: null,
|
||||
image: null,
|
||||
homeBoardId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -297,6 +299,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
{
|
||||
id: userToDelete,
|
||||
@@ -306,6 +309,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
@@ -315,6 +319,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user` ADD `homeBoardId` varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD CONSTRAINT `user_homeBoardId_board_id_fk` FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1715334452118,
|
||||
"tag": "0000_harsh_photon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1715885855801,
|
||||
"tag": "0001_wild_alex_wilder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = OFF;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text,
|
||||
`email` text,
|
||||
`emailVerified` integer,
|
||||
`image` text,
|
||||
`password` text,
|
||||
`salt` text,
|
||||
`homeBoardId` text,
|
||||
FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `user` SELECT `id`, `name`, `email`, `emailVerified`, `image`, `password`, `salt`, null FROM `__user_old`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `__user_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__user_old` RENAME TO `user`;
|
||||
--> statement-breakpoint
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = ON;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1715334238443,
|
||||
"tag": "0000_talented_ben_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1715871797713,
|
||||
"tag": "0001_mixed_titanium_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import { relations } from "drizzle-orm";
|
||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
@@ -36,6 +37,12 @@ export const users = mysqlTable("user", {
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
homeBoardId: varchar("homeBoardId", { length: 64 }).references(
|
||||
(): AnyMySqlColumn => boards.id,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const accounts = mysqlTable(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
index,
|
||||
int,
|
||||
@@ -35,6 +36,12 @@ export const users = sqliteTable("user", {
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
homeBoardId: text("homeBoardId").references(
|
||||
(): AnySQLiteColumn => boards.id,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
|
||||
@@ -4,11 +4,16 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import { schema } from "..";
|
||||
|
||||
export const createDb = () => {
|
||||
export const createDb = (debug?: boolean) => {
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = drizzle(sqlite, { schema });
|
||||
const db = drizzle(sqlite, { schema, logger: debug });
|
||||
migrate(db, {
|
||||
migrationsFolder: "./packages/db/migrations/sqlite",
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log("Database created");
|
||||
}
|
||||
|
||||
return db;
|
||||
};
|
||||
|
||||
@@ -502,7 +502,7 @@ export default {
|
||||
preferences: "Your preferences",
|
||||
logout: "Logout",
|
||||
login: "Login",
|
||||
navigateDefaultBoard: "Navigate to default board",
|
||||
homeBoard: "Your home board",
|
||||
loggedOut: "Logged out",
|
||||
},
|
||||
},
|
||||
@@ -970,6 +970,9 @@ export default {
|
||||
label: "Name",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
metaTitle: "{boardName} board",
|
||||
},
|
||||
setting: {
|
||||
title: "Settings for {boardName} board",
|
||||
section: {
|
||||
@@ -1152,6 +1155,13 @@ export default {
|
||||
settings: {
|
||||
label: "Settings",
|
||||
},
|
||||
setHomeBoard: {
|
||||
label: "Set as your home board",
|
||||
badge: {
|
||||
label: "Home",
|
||||
tooltip: "This board will show as your home board",
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete permanently",
|
||||
confirm: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
|
||||
import { ClientServerDataInitalizer } from "./client";
|
||||
import { GlobalItemServerDataProvider } from "./provider";
|
||||
|
||||
type Board = RouterOutputs["board"]["getDefaultBoard"];
|
||||
type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
shouldRun: boolean;
|
||||
|
||||
Reference in New Issue
Block a user