Skip to content

Commit

Permalink
Merge pull request #35 from codegasms/feat/page-listings
Browse files Browse the repository at this point in the history
Create listing page for users in an org
  • Loading branch information
aahnik authored Dec 4, 2024
2 parents 5d8706b + 52d965f commit b21c403
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 20 deletions.
163 changes: 161 additions & 2 deletions app/[orgId]/users/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,164 @@
import { UserListPage } from "@/components/users-table";
"use client";

import { GenericListing, ColumnDef } from "@/mint/generic-listing";
import { GenericEditor, Field } from "@/mint/generic-editor";
import { inviteUserSchema } from "@/lib/validations";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

interface User {
id: number;
name: string;
nameId: string;
avatar?: string;
about?: string;
role: "owner" | "organizer" | "member";
joinedAt: string;
}

interface InviteUserData {
email: string;
role: "owner" | "organizer" | "member";
}

const columns: ColumnDef<User>[] = [
{ header: "Name", accessorKey: "name" },
{ header: "Username", accessorKey: "nameId" },
{ header: "Role", accessorKey: "role" },
{ header: "Joined", accessorKey: "joinedAt" },
{
header: "About",
accessorKey: "about",
// cell: ({ getValue }) => getValue() || "No description"
},
];

const fields: Field[] = [
{ name: "email", label: "Email", type: "text" },
{ name: "role", label: "Role", type: "text" },
/*
{
name: "role",
label: "Role",
type: "select",
options: [
{ value: "member", label: "Member" },
{ value: "organizer", label: "Organizer" },
{ value: "owner", label: "Owner" }
]
}
*/
];

export default function UsersPage() {
return <UserListPage />;
const params = useParams();
const orgId = params.orgId as string;

const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);

useEffect(() => {
fetchUsers();
}, [orgId]);

const fetchUsers = async () => {
try {
const response = await fetch(`/api/orgs/${orgId}/users`);
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data);
} catch (error) {
console.error("Error fetching users:", error);
}
};

const inviteUser = async (data: InviteUserData) => {
try {
const response = await fetch(`/api/orgs/${orgId}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to invite user");
}

await fetchUsers();
setIsEditorOpen(false);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};

const updateRole = async (user: User) => {
try {
const response = await fetch(`/api/orgs/${orgId}/users/${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: user.role }),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update role");
}

await fetchUsers();
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};

const removeUser = async (user: User) => {
try {
const response = await fetch(`/api/orgs/${orgId}/users/${user.id}`, {
method: "DELETE",
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to remove user");
}

setUsers(users.filter((u) => u.id !== user.id));
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};

return (
<>
<GenericListing
data={users}
columns={columns}
title="Organization Users"
searchableFields={["name", "nameId"]}
onAdd={() => {
setSelectedUser(null);
setIsEditorOpen(true);
}}
onEdit={(user) => {
setSelectedUser(user);
setIsEditorOpen(true);
}}
onDelete={removeUser}
/>

<GenericEditor
data={selectedUser}
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
onSave={selectedUser ? updateRole : inviteUser}
schema={inviteUserSchema}
fields={fields}
title={selectedUser ? "Update Role" : "Invite User"}
/>
</>
);
}
2 changes: 1 addition & 1 deletion app/api/orgs/[orgId]/contests/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { db } from "@/db/drizzle";
import { contests } from "@/db/schema";
import { createContestSchema } from "./validation";
import { createContestSchema } from "@/lib/validations";

export async function createContest(
orgId: number,
Expand Down
51 changes: 51 additions & 0 deletions app/api/orgs/[orgId]/users/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import * as userService from "./service";
import { IdSchema } from "@/app/api/types";
import { updateUserRoleSchema } from "@/lib/validations";
import { z } from "zod";

export async function DELETE(
_req: NextRequest,
{ params }: { params: { orgId: string; userId: string } },
) {
try {
const orgId = IdSchema.parse(params.orgId);
const userId = IdSchema.parse(params.userId);

const deleted = await userService.removeUserFromOrg(orgId, userId);
return NextResponse.json(deleted);
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 404 });
}
return NextResponse.json(
{ message: "Failed to remove user" },
{ status: 500 },
);
}
}

export async function PATCH(
_req: NextRequest,
{ params }: { params: { orgId: string; userId: string } },
) {
try {
const orgId = IdSchema.parse(params.orgId);
const userId = IdSchema.parse(params.userId);
const { role } = updateUserRoleSchema.parse(await _req.json());

const updated = await userService.updateUserRole(orgId, userId, role);
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 404 });
}
return NextResponse.json(
{ message: "Failed to update role" },
{ status: 500 },
);
}
}
32 changes: 32 additions & 0 deletions app/api/orgs/[orgId]/users/[userId]/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { db } from "@/db/drizzle";
import { memberships } from "@/db/schema";
import { eq, and } from "drizzle-orm";

export async function removeUserFromOrg(orgId: number, userId: number) {
const [deleted] = await db
.delete(memberships)
.where(and(eq(memberships.orgId, orgId), eq(memberships.userId, userId)))
.returning();

if (!deleted) {
throw new Error("Membership not found");
}
return deleted;
}

export async function updateUserRole(
orgId: number,
userId: number,
role: string,
) {
const [updated] = await db
.update(memberships)
.set({ role })
.where(and(eq(memberships.orgId, orgId), eq(memberships.userId, userId)))
.returning();

if (!updated) {
throw new Error("Membership not found");
}
return updated;
}
45 changes: 45 additions & 0 deletions app/api/orgs/[orgId]/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import * as userService from "./service";
import { IdSchema } from "@/app/api/types";
import { inviteUserSchema } from "@/lib/validations";
import { z } from "zod";

export async function GET(
_req: NextRequest,
{ params }: { params: { orgId: string } },
) {
try {
const orgId = IdSchema.parse(params.orgId);
const users = await userService.getOrgUsers(orgId);
return NextResponse.json(users);
} catch (error) {
return NextResponse.json(
{ message: "Failed to fetch users" },
{ status: 500 },
);
}
}

export async function POST(
request: NextRequest,
{ params }: { params: { orgId: string } },
) {
try {
const orgId = IdSchema.parse(params.orgId);
const data = inviteUserSchema.parse(await request.json());

const membership = await userService.inviteUser(orgId, data);
return NextResponse.json(membership, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: "Failed to invite user" },
{ status: 500 },
);
}
}
64 changes: 64 additions & 0 deletions app/api/orgs/[orgId]/users/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { db } from "@/db/drizzle";
import { users, memberships } from "@/db/schema";
import { inviteUserSchema } from "@/lib/validations";
import { eq, and } from "drizzle-orm";
import { z } from "zod";

export async function getOrgUsers(orgId: number) {
return await db
.select({
id: users.id,
name: users.name,
nameId: users.nameId,
avatar: users.avatar,
about: users.about,
role: memberships.role,
joinedAt: memberships.joinedAt,
})
.from(users)
.innerJoin(memberships, eq(memberships.userId, users.id))
.where(eq(memberships.orgId, orgId))
.orderBy(memberships.joinedAt);
}

export async function inviteUser(
orgId: number,
data: z.infer<typeof inviteUserSchema>,
) {
return await db.transaction(async (tx) => {
const user = await tx
.select({
id: users.id,
})
.from(users)
.where(eq(users.email, data.email))
.limit(1);

if (user.length === 0) {
throw new Error("User not found");
}

const existingMembership = await tx
.select()
.from(memberships)
.where(
and(eq(memberships.userId, user[0].id), eq(memberships.orgId, orgId)),
)
.limit(1);

if (existingMembership.length > 0) {
throw new Error("User is already a member");
}

const [membership] = await tx
.insert(memberships)
.values({
userId: user[0].id,
orgId,
role: data.role,
})
.returning();

return membership;
});
}
Loading

0 comments on commit b21c403

Please sign in to comment.