From 6e2a9eee9f807e06f99db00bcd7007dbb65d6184 Mon Sep 17 00:00:00 2001
From: Claas
Date: Tue, 16 Jun 2026 23:40:47 +0000
Subject: [PATCH] Implement project CRUD operations
---
client/src/branded.ts | 1 -
client/src/organization.ts | 12 +-
client/src/project.ts | 43 ++++-
.../src/routes/_app/organizations/index.tsx | 8 +-
client/src/routes/_app/projects.tsx | 87 ++++++++--
client/src/routes/organizations/$id/edit.tsx | 7 +-
client/src/routes/projects/$id.edit.tsx | 153 ++++++++++++++++++
client/src/routes/projects/new.tsx | 6 +-
.../main/java/com/yealch/yealch/Project.java | 12 ++
.../com/yealch/yealch/ProjectsController.java | 23 +++
.../com/yealch/yealch/UsersController.java | 35 +++-
11 files changed, 351 insertions(+), 36 deletions(-)
delete mode 100644 client/src/branded.ts
create mode 100644 client/src/routes/projects/$id.edit.tsx
create mode 100644 server/src/main/java/com/yealch/yealch/ProjectsController.java
diff --git a/client/src/branded.ts b/client/src/branded.ts
deleted file mode 100644
index 91bb85a..0000000
--- a/client/src/branded.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type ProjectId = string & { __brand: "ProjectId" };
diff --git a/client/src/organization.ts b/client/src/organization.ts
index 06e91de..f6d4345 100644
--- a/client/src/organization.ts
+++ b/client/src/organization.ts
@@ -44,11 +44,9 @@ export async function createOrganization(userId: UserId, name: string) {
const response = await fetch(`/api/users/${userId}/organizations`, {
method: "POST",
headers: {
- "Content-Type": "application/json",
+ "Content-Type": "text/plain",
},
- body: JSON.stringify({
- name,
- }),
+ body: name,
});
if (!response.ok) {
@@ -62,7 +60,7 @@ export async function updateOrganizationName(userId: UserId, id: Id, name: strin
const response = await fetch(`/api/users/${userId}/organizations/${id}/name`, {
method: "PUT",
headers: {
- "Content-Type": "application/json",
+ "Content-Type": "text/plain",
},
body: name,
});
@@ -73,3 +71,7 @@ export async function updateOrganizationName(userId: UserId, id: Id, name: strin
throw new Error("Failed to update organization name. See console for more details.");
}
}
+
+export const isOrganization = (
+ organization: Organization | OptimisticOrganization,
+): organization is Organization => "id" in organization;
diff --git a/client/src/project.ts b/client/src/project.ts
index b1b0f72..40c5fcf 100644
--- a/client/src/project.ts
+++ b/client/src/project.ts
@@ -1,10 +1,12 @@
import { queryOptions, useQueryClient } from "@tanstack/solid-query";
import { createResource } from "solid-js";
-import type { ProjectId } from "./branded";
+import { type Organization, type Id as OrganizationId } from "./organization";
import { idQuery, QUERY_BASE } from "./user";
-export type Project = { id: ProjectId; name: string };
+export type Id = string & { __brand: "ProjectId" };
+export type Project = { id: Id; name: string; organization: Organization };
+export type OptimisticProject = Omit;
export function useProjects() {
const queryClient = useQueryClient();
@@ -25,12 +27,13 @@ export function useProjects() {
return projects;
}
-export const query = (userId: string | undefined) =>
+export const query = (userId: string | undefined, signal?: AbortSignal) =>
queryOptions({
queryKey: [QUERY_BASE, userId, "projects"],
- async queryFn() {
+ async queryFn(): Promise<(Project | OptimisticProject)[]> {
const response = await fetch(`/api/users/${userId}/projects`, {
method: "GET",
+ signal,
});
if (!response.ok)
@@ -41,3 +44,35 @@ export const query = (userId: string | undefined) =>
},
enabled: Boolean(userId),
});
+
+export type UpdateProjectParameters = {
+ userId: string;
+ id: Id;
+ name: string;
+ organizationId: OrganizationId;
+};
+
+export async function updateProject({ userId, id, ...parameters }: UpdateProjectParameters) {
+ const response = await fetch(`/api/users/${userId}/projects/${id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(parameters),
+ });
+
+ if (!response.ok)
+ throw new Error(`Error updating project: ${response.status} ${await response.text()}`);
+}
+
+export const isProject = (project: Project | OptimisticProject): project is Project =>
+ "id" in project;
+
+export async function deleteProject(id: Id) {
+ const response = await fetch(`/api/projects/${id}`, {
+ method: "DELETE",
+ });
+
+ if (!response.ok)
+ throw new Error(`Failed to delete project. See network response for more details.`);
+}
diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx
index 451f671..a1f2502 100644
--- a/client/src/routes/_app/organizations/index.tsx
+++ b/client/src/routes/_app/organizations/index.tsx
@@ -5,9 +5,8 @@ import { For, Show } from "solid-js";
import Icon from "../../../Icon";
import {
deleteOrganization,
+ isOrganization,
query,
- type OptimisticOrganization,
- type Organization,
type Id as OrganizationId,
} from "../../../organization";
import { Title } from "../../../Title";
@@ -49,10 +48,6 @@ function Organizations() {
const isLastOrganization = () =>
organizationsQuery.status === "success" && organizationsQuery.data?.length === 1;
- const isOrganization = (
- organization: Organization | OptimisticOrganization,
- ): organization is Organization => "id" in organization;
-
return (
<>
@@ -86,6 +81,7 @@ function Organizations() {
diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx
index 16c4bce..b2e4076 100644
--- a/client/src/routes/_app/projects.tsx
+++ b/client/src/routes/_app/projects.tsx
@@ -1,12 +1,20 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, Link } from "@tanstack/solid-router";
-import { For, type VoidProps } from "solid-js";
+import { For, Show, type VoidProps } from "solid-js";
import Icon from "../../Icon";
-import { useProjects, type Project } from "../../project";
+import { deleteProject, isProject, query, type Id as ProjectId, type Project } from "../../project";
import { Title } from "../../Title";
+import { idQuery } from "../../user";
export const Route = createFileRoute("/_app/projects")({
component: Projects,
+ async loader({ context: { queryClient }, abortController }) {
+ const userId = await queryClient.ensureQueryData(idQuery);
+ const projects = await queryClient.ensureQueryData(query(userId, abortController.signal));
+
+ return { projects, userId };
+ },
});
function Card(properties: VoidProps<{ project: Project }>) {
@@ -14,23 +22,78 @@ function Card(properties: VoidProps<{ project: Project }>) {
}
function Projects() {
- const projects = useProjects();
+ const routeData = Route.useLoaderData();
+ const userId = () => routeData().userId;
+
+ const options = () => query(userId());
+ const projectsQuery = useQuery(options);
+ const queryClient = useQueryClient();
+
+ const deleteMutation = useMutation(() => {
+ const currentUserId = userId();
+ const queryKey = ["user", currentUserId, "projects"];
+
+ return {
+ mutationFn: deleteProject,
+ async onSettled() {
+ await queryClient.invalidateQueries({ queryKey });
+ },
+ };
+ });
+
+ const isDeleting = (id: ProjectId) => deleteMutation.isPending && deleteMutation.variables === id;
+
+ const isLastProject = () =>
+ projectsQuery.status === "success" && projectsQuery.data?.length === 1;
return (
<>
+
Create project
-
-
-
-
}>
- {(project) => }
-
-
-
+ {/* TODO overflow, pagination, scrolling */}
+
+
+ Loading projects...}>
+ {(project) => {
+ return (
+
+ -
+ {project.name}
+
+
+ {(id) => (
+ <>
+
+ Edit
+
+
+
+ >
+ )}
+
+
+
+ );
+ }}
+
+
>
);
diff --git a/client/src/routes/organizations/$id/edit.tsx b/client/src/routes/organizations/$id/edit.tsx
index bc4689f..445aa55 100644
--- a/client/src/routes/organizations/$id/edit.tsx
+++ b/client/src/routes/organizations/$id/edit.tsx
@@ -5,7 +5,6 @@ import Body from "../../../Body";
import Icon from "../../../Icon";
import { query, updateOrganizationName, type Id as OrganizationId } from "../../../organization";
import { idQuery, type Id as UserId } from "../../../user";
-import { Route as OrganizationsRoute } from "../../_app/organizations";
export const Route = createFileRoute("/organizations/$id/edit")({
component: RouteComponent,
@@ -43,11 +42,11 @@ function RouteComponent() {
// Optimistically update to the new value
context.client.setQueryData(queryOptions.queryKey, (old) => {
- const org = old?.find(
+ const organization = old?.find(
(organization) => "id" in organization && organization.id === variables.id,
);
- if (org) {
- org.name = variables.name;
+ if (organization) {
+ organization.name = variables.name;
}
return old;
});
diff --git a/client/src/routes/projects/$id.edit.tsx b/client/src/routes/projects/$id.edit.tsx
new file mode 100644
index 0000000..e0eff14
--- /dev/null
+++ b/client/src/routes/projects/$id.edit.tsx
@@ -0,0 +1,153 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query";
+import { createFileRoute, Link, useNavigate } from "@tanstack/solid-router";
+import { For, Suspense } from "solid-js";
+
+import Body from "../../Body";
+import Icon from "../../Icon";
+import {
+ query as organizationQuery,
+ isOrganization,
+ type Id as OrganizationId,
+} from "../../organization";
+import { query, updateProject, type Id as ProjectId } from "../../project";
+import { idQuery } from "../../user";
+
+export const Route = createFileRoute("/projects/$id/edit")({
+ component: RouteComponent,
+ async loader({ context }) {
+ const userId = await context.queryClient.ensureQueryData(idQuery);
+ return { userId };
+ },
+ validateSearch(search) {
+ if (
+ search.name &&
+ typeof search.name === "string" &&
+ search.organizationId &&
+ typeof search.organizationId === "string"
+ )
+ return { name: search.name, organizationId: search.organizationId };
+
+ return {};
+ },
+});
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const search = Route.useSearch();
+ const loaderData = Route.useLoaderData();
+ const parameters = Route.useParams();
+ const userIdQuery = useQuery(() => idQuery);
+ const organizations = useQuery(() => organizationQuery(userIdQuery.data));
+
+ const updateNameMutation = useMutation(() => ({
+ mutationFn: updateProject,
+ async onMutate(variables, context) {
+ const userId = await context.client.fetchQuery(idQuery);
+ const queryOptions = query(userId);
+ // Cancel any outgoing refetches
+ // (so they don't overwrite our optimistic update)
+ await context.client.cancelQueries(queryOptions);
+
+ // Snapshot the previous value
+ const previous = context.client.getQueryData(queryOptions.queryKey);
+
+ // Optimistically update to the new value
+ context.client.setQueryData(queryOptions.queryKey, (old) => {
+ const project = old?.find((project) => "id" in project && project.id === variables.id);
+ if (project) {
+ project.name = variables.name;
+ }
+ return old;
+ });
+
+ // Return the snapshotted value
+ return { userId, previous };
+ },
+
+ async onError(_error, variables, onMutateResult, context) {
+ context.client.setQueryData(query(onMutateResult?.userId).queryKey, onMutateResult?.previous);
+ await navigate({
+ to: "/projects/$id/edit",
+ params: { id: variables.id },
+ search: { name: variables.name, organizationId: variables.organizationId },
+ });
+ },
+ async onSuccess(_data, _error, variables) {
+ await queryClient.invalidateQueries({ queryKey: query(variables.userId).queryKey });
+ },
+ }));
+
+ async function handleSubmit(event: SubmitEvent) {
+ event.preventDefault();
+
+ const form = event.currentTarget as HTMLFormElement;
+ const nameInput = form.elements.namedItem("name") as HTMLInputElement;
+ const name = nameInput.value;
+ const userId = loaderData().userId;
+ const organizationSelect = form.elements.namedItem("organization") as HTMLSelectElement;
+ const organizationId = organizationSelect.value as OrganizationId;
+ //TODO find more elegant way to casting to ProjectId
+ const id = parameters().id as ProjectId;
+ updateNameMutation.mutate({ userId, id, name, organizationId });
+ await navigate({ to: "/projects" });
+ }
+
+ const selectableOrganizations = () => organizations.data?.filter(isOrganization);
+
+ return (
+
+
+
+ Discard
+
+
+ Edit Project
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/routes/projects/new.tsx b/client/src/routes/projects/new.tsx
index ab2f3f0..4b70318 100644
--- a/client/src/routes/projects/new.tsx
+++ b/client/src/routes/projects/new.tsx
@@ -4,7 +4,7 @@ import { For, Suspense } from "solid-js";
import Body from "../../Body";
import Icon from "../../Icon";
-import { query } from "../../organization";
+import { isOrganization, query } from "../../organization";
import { idQuery } from "../../user";
export const Route = createFileRoute("/projects/new")({
@@ -48,6 +48,8 @@ function RouteComponent() {
navigate({ to: "/projects" });
}
+ const selectableOrganizations = () => organizations.data?.filter(isOrganization);
+
return (
@@ -80,7 +82,7 @@ function RouteComponent() {
}
>
-
+
{(organization) => }
diff --git a/server/src/main/java/com/yealch/yealch/Project.java b/server/src/main/java/com/yealch/yealch/Project.java
index 7352178..8e8ee68 100644
--- a/server/src/main/java/com/yealch/yealch/Project.java
+++ b/server/src/main/java/com/yealch/yealch/Project.java
@@ -51,4 +51,16 @@ public class Project {
public Organization getOrganization() {
return organization;
}
+
+ public Long getOrganizationId() {
+ return organizationId;
+ }
+
+ public Organization getOrganizationEntity() {
+ return organization;
+ }
+
+ public void setOrganizationId(Long organizationId) {
+ this.organizationId = organizationId;
+ }
}
diff --git a/server/src/main/java/com/yealch/yealch/ProjectsController.java b/server/src/main/java/com/yealch/yealch/ProjectsController.java
new file mode 100644
index 0000000..7efcc2a
--- /dev/null
+++ b/server/src/main/java/com/yealch/yealch/ProjectsController.java
@@ -0,0 +1,23 @@
+package com.yealch.yealch;
+
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/organizations")
+public class ProjectsController {
+
+ private final ProjectRepository projectRepository;
+
+ public ProjectsController(ProjectRepository projectRepository) {
+ this.projectRepository = projectRepository;
+ }
+
+ @DeleteMapping("/{projectId}")
+ public void deleteProject(@PathVariable Long projectId) {
+ // TODO validate user is authorized to delete project
+ projectRepository.deleteById(projectId);
+ }
+}
diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java
index 4dede7c..56e8adc 100644
--- a/server/src/main/java/com/yealch/yealch/UsersController.java
+++ b/server/src/main/java/com/yealch/yealch/UsersController.java
@@ -286,11 +286,10 @@ public class UsersController {
/** Creates a new organization with this user in it */
@PostMapping("/api/users/{userId}/organizations")
public ResponseEntity> createOrganizationForUser(@PathVariable Long userId,
- @RequestBody Map request) {
+ @RequestBody String organizationName) {
// TODO check on authorization if the user id is the same user that sends the
// request
- String organizationName = request.get("name");
if (organizationName == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required"));
}
@@ -332,4 +331,36 @@ public class UsersController {
.body(Map.of("error", "organization not found"))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found")));
}
+
+ record UpdateProjectRequest(String name, Long organizationId) {
+ }
+
+ @PutMapping("/api/users/{userId}/projects/{projectId}")
+ public ResponseEntity> updateProject(@PathVariable Long userId, @PathVariable Long projectId,
+ @RequestBody UpdateProjectRequest request) {
+
+ //
+ if (request == null || request.name() == null || request.organizationId() == null) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+ .body(Map.of("error", "name and organizationId are required"));
+ }
+
+ return userRepository.findById(userId)
+ .>map(user -> projectRepository.findById(projectId)
+ .>map(project -> {
+ if (!project.getOrganization().getMembers().contains(user)) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(Map.of("error", "user is not a member of the project"));
+ }
+
+ project.setName(request.name());
+ project.setOrganizationId(request.organizationId());
+ projectRepository.save(project);
+
+ return ResponseEntity.ok().build();
+ })
+ .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(Map.of("error", "project not found"))))
+ .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found")));
+ }
}
--
2.51.2