Repository: simonknittel/sinister-incorporated
Pull request #1242: Production deployment
Author: login: github-actions[bot]
Created: 2025-04-13T09:45:45Z | Merged: 2025-04-13T09:47:07Z

**Only merge using a merge commit!**


Diff:
diff --git a/app/prisma/migrations/20250413085117_add_tgask_visibility_group/migration.sql b/app/prisma/migrations/20250413085117_add_tgask_visibility_group/migration.sql
new file mode 100644
index 000000000..7e274ffa6
--- /dev/null
+++ b/app/prisma/migrations/20250413085117_add_tgask_visibility_group/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "TaskVisibility" ADD VALUE 'GROUP';
diff --git a/app/src/app/app/changelog/page.tsx b/app/src/app/app/changelog/page.tsx
index 4be512f5d..a5bb41eb8 100644
--- a/app/src/app/app/changelog/page.tsx
+++ b/app/src/app/app/changelog/page.tsx
@@ -46,6 +46,14 @@ export default async function Page() {
               Diese Aufgabe kann nur von ihnen gesehen und erfüllt werden.
             </p>
 
+            <p>
+              <strong>Task erstellen</strong>
+            </p>
+            <p>
+              Bei Mehrfachauswahl von Citizen kann nun eine Rolle ausgewählt
+              werden um direkt alle Citizen mit dieser Rolle hinzuzufügen.
+            </p>
+
             <p>
               <strong>Task abschließen</strong>
             </p>
diff --git a/app/src/server/api/routers/citizens.ts b/app/src/server/api/routers/citizens.ts
index 7786af5d2..d87eb1296 100644
--- a/app/src/server/api/routers/citizens.ts
+++ b/app/src/server/api/routers/citizens.ts
@@ -1,5 +1,7 @@
 import { prisma } from "@/db";
 import { log } from "@/logging";
+import { getVisibleRoles } from "@/roles/utils/getRoles";
+import type { Entity, Role, Upload } from "@prisma/client";
 import { TRPCError } from "@trpc/server";
 import { serializeError } from "serialize-error";
 import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -30,4 +32,57 @@ export const citizensRouter = createTRPCRouter({
       });
     }
   }),
+
+  getCitizensGroupedByVisibleRoles: protectedProcedure.query(async () => {
+    try {
+      const citizens = await prisma.entity.findMany({
+        where: {
+          roles: {
+            not: null,
+          },
+        },
+        orderBy: {
+          handle: "asc",
+        },
+      });
+
+      const visibleRoles = await getVisibleRoles();
+
+      const groupedCitizens = new Map<
+        string,
+        {
+          role: Role & {
+            icon: Upload | null;
+          };
+          citizens: Entity[];
+        }
+      >();
+
+      for (const citizen of citizens) {
+        const citizenRoleIds = citizen.roles?.split(",") ?? [];
+        for (const citizenRoleId of citizenRoleIds) {
+          const role = visibleRoles.find((r) => r.id === citizenRoleId);
+
+          if (role) {
+            if (!groupedCitizens.has(role.id)) {
+              groupedCitizens.set(role.id, { role, citizens: [] });
+            }
+
+            groupedCitizens.get(role.id)?.citizens.push(citizen);
+          }
+        }
+      }
+
+      return groupedCitizens;
+    } catch (error) {
+      void log.error("Failed to fetch citizens", {
+        error: serializeError(error),
+      });
+
+      throw new TRPCError({
+        code: "INTERNAL_SERVER_ERROR",
+        message: "Failed to fetch citizens",
+      });
+    }
+  }),
 });
diff --git a/app/src/spynet/components/CitizenInput.tsx b/app/src/spynet/components/CitizenInput.tsx
index 801118b92..f8209cdde 100644
--- a/app/src/spynet/components/CitizenInput.tsx
+++ b/app/src/spynet/components/CitizenInput.tsx
@@ -1,7 +1,9 @@
 "use client";
 
 import { useAuthentication } from "@/auth/hooks/useAuthentication";
+import Button from "@/common/components/Button";
 import { CitizenLink } from "@/common/components/CitizenLink";
+import { SingleRole } from "@/common/components/SingleRole";
 import { api } from "@/trpc/react";
 import {
   Combobox,
@@ -10,9 +12,10 @@ import {
   ComboboxOptions,
 } from "@headlessui/react";
 import type { Entity } from "@prisma/client";
+import * as Popover from "@radix-ui/react-popover";
 import clsx from "clsx";
-import { useState } from "react";
-import { FaCheck, FaTrash } from "react-icons/fa";
+import { useRef, useState } from "react";
+import { FaCheck, FaTrash, FaUsers } from "react-icons/fa";
 
 type BaseProps = Readonly<{
   className?: string;
@@ -45,17 +48,15 @@ export const CitizenInput = ({
 }: Props) => {
   const [query, setQuery] = useState("");
 
-  const { isPending, data: allCitizens } = api.citizens.getAllCitizens.useQuery(
-    undefined,
-    {
+  const { isPending: isPendingAllCitizens, data: dataAllCitizens } =
+    api.citizens.getAllCitizens.useQuery(undefined, {
       refetchOnWindowFocus: false,
       refetchOnReconnect: false,
-    },
-  );
+    });
 
   const filteredCitizens =
-    allCitizens && query.trim()
-      ? allCitizens
+    dataAllCitizens && query.trim()
+      ? dataAllCitizens
           .filter((citizen) =>
             citizen.handle!.toLowerCase().includes(query.trim().toLowerCase()),
           )
@@ -70,12 +71,14 @@ export const CitizenInput = ({
         name={name}
         query={query}
         setQuery={setQuery}
-        isPending={isPending}
+        isPendingAllCitizens={isPendingAllCitizens}
         filteredCitizens={filteredCitizens}
         defaultValue={
           defaultValue
             ? (defaultValue
-                .map((id) => allCitizens?.find((citizen) => citizen.id === id))
+                .map((id) =>
+                  dataAllCitizens?.find((citizen) => citizen.id === id),
+                )
                 .filter(Boolean) as Entity[])
             : undefined
         }
@@ -87,12 +90,12 @@ export const CitizenInput = ({
       className={className}
       name={name}
       setQuery={setQuery}
-      isPending={isPending}
+      isPending={isPendingAllCitizens}
       filteredCitizens={filteredCitizens}
       disabled={disabled}
       defaultValue={
         defaultValue
-          ? allCitizens?.find((citizen) => citizen.id === defaultValue)
+          ? dataAllCitizens?.find((citizen) => citizen.id === defaultValue)
           : undefined
       }
       autoFocus={autoFocus}
@@ -181,7 +184,7 @@ type MultipleComponentProps = Readonly<{
   name: string;
   query: string;
   setQuery: (query: string) => void;
-  isPending: boolean;
+  isPendingAllCitizens: boolean;
   filteredCitizens: Entity[];
   defaultValue?: Entity[];
   autoFocus?: boolean;
@@ -192,16 +195,39 @@ const Multiple = ({
   name,
   query,
   setQuery,
-  isPending,
+  isPendingAllCitizens,
   filteredCitizens,
   defaultValue,
   autoFocus,
 }: MultipleComponentProps) => {
+  const authentication = useAuthentication();
+  if (!authentication) throw new Error("Forbidden");
+
+  const {
+    isPending: isPendingCitizensGroupedByVisibleRoles,
+    data: dataCitizensGroupedByVisibleRoles,
+  } = api.citizens.getCitizensGroupedByVisibleRoles.useQuery(undefined, {
+    refetchOnWindowFocus: false,
+    refetchOnReconnect: false,
+  });
+
   const [selectedCitizens, setSelectedCitizens] = useState<Entity[]>(
     defaultValue || [],
   );
-  const authentication = useAuthentication();
-  if (!authentication) throw new Error("Forbidden");
+
+  const isPending =
+    isPendingAllCitizens || isPendingCitizensGroupedByVisibleRoles;
+
+  const handleSelectRole = (roleId: string) => {
+    if (!dataCitizensGroupedByVisibleRoles) return;
+
+    setSelectedCitizens(
+      dataCitizensGroupedByVisibleRoles.get(roleId)?.citizens || [],
+    );
+  };
+  const [isOpen, setIsOpen] = useState(false);
+
+  const popoverPortalRef = useRef<HTMLDivElement | null>(null);
 
   return (
     <div className={clsx(className)}>
@@ -210,42 +236,89 @@ const Multiple = ({
       {isPending ? (
         <div className="h-10 animate-pulse rounded bg-neutral-900" />
       ) : (
-        <Combobox
-          multiple
-          value={selectedCitizens}
-          onChange={(citizens) => {
-            setSelectedCitizens(citizens);
-            setQuery("");
-          }}
-          onClose={() => setQuery("")}
-        >
-          <ComboboxInput
-            autoFocus={autoFocus}
-            aria-label="Citizens"
-            value={query}
-            onChange={(event) => setQuery(event.target.value)}
-            className="w-full rounded bg-neutral-900 py-2 pr-8 pl-2 focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25"
-          />
-
-          <ComboboxOptions
-            anchor="bottom"
-            className="w-[var(--input-width)] rounded-b border border-sinister-red-500 bg-black p-1 [--anchor-gap:var(--spacing-1)] empty:invisible transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0 z-50"
+        <div className="flex gap-2">
+          <Combobox
+            multiple
+            value={selectedCitizens}
+            onChange={(citizens) => {
+              setSelectedCitizens(citizens);
+              setQuery("");
+            }}
+            onClose={() => setQuery("")}
           >
-            {filteredCitizens.map((citizen) => (
-              <ComboboxOption
-                key={citizen.id}
-                value={citizen}
-                className="group flex cursor-pointer items-center gap-2 rounded py-1 px-2 select-none data-[focus]:bg-white/20"
+            <ComboboxInput
+              autoFocus={autoFocus}
+              aria-label="Citizens"
+              value={query}
+              onChange={(event) => setQuery(event.target.value)}
+              className="w-full rounded bg-neutral-900 py-2 pr-8 pl-2 focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25"
+            />
+
+            <ComboboxOptions
+              anchor="bottom"
+              className="w-[var(--input-width)] rounded-b border border-sinister-red-500 bg-black p-1 [--anchor-gap:var(--spacing-1)] empty:invisible transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0 z-50"
+            >
+              {filteredCitizens.map((citizen) => (
+                <ComboboxOption
+                  key={citizen.id}
+                  value={citizen}
+                  className="group flex cursor-pointer items-center gap-2 rounded py-1 px-2 select-none data-[focus]:bg-white/20"
+                >
+                  <FaCheck className="invisible group-data-[selected]:visible text-sm text-sinister-red-500" />
+
+                  <div className="text-white text-sm">{citizen.handle!}</div>
+
+                  <div className="text-xs text-neutral-500">{citizen.id}</div>
+                </ComboboxOption>
+              ))}
+            </ComboboxOptions>
+          </Combobox>
+
+          <Popover.Root open={isOpen} onOpenChange={setIsOpen}>
+            <Popover.Trigger asChild>
+              <Button
+                type="button"
+                title="Rolle auswählen"
+                variant="secondary"
+                iconOnly
+                className="flex-none"
               >
-                <FaCheck className="invisible group-data-[selected]:visible text-sm text-sinister-red-500" />
+                <FaUsers />
+              </Button>
+            </Popover.Trigger>
 
-                <div className="text-white text-sm">{citizen.handle!}</div>
+            {/* eslint-disable-next-line react-compiler/react-compiler */}
+            <Popover.Portal container={popoverPortalRef.current}>
+              <Popover.Content sideOffset={4} side="top">
+                <div className="flex flex-col gap-2 p-4 rounded bg-neutral-800 border border-sinister-red-500 max-h-96 overflow-auto">
+                  {dataCitizensGroupedByVisibleRoles
+                    ? Array.from(dataCitizensGroupedByVisibleRoles.values())
+                        .toSorted((a, b) =>
+                          a.role.name.localeCompare(b.role.name),
+                        )
+                        .map(({ role }) => (
+                          <button
+                            key={role.id}
+                            type="button"
+                            onClick={() => handleSelectRole(role.id)}
+                            className="group"
+                          >
+                            <SingleRole
+                              role={role}
+                              showPlaceholder
+                              className="bg-transparent group-hover:bg-neutral-700/50 group-focus-visible:bg-neutral-700/50"
+                            />
+                          </button>
+                        ))
+                    : null}
+                </div>
 
-                <div className="text-xs text-neutral-500">{citizen.id}</div>
-              </ComboboxOption>
-            ))}
-          </ComboboxOptions>
-        </Combobox>
+                <Popover.Arrow className="fill-neutral-800" />
+              </Popover.Content>
+            </Popover.Portal>
+          </Popover.Root>
+          <div ref={popoverPortalRef} className="z-10" />
+        </div>
       )}
 
       <p className="text-xs mt-1 text-gray-400">Mehrfachauswahl möglich</p>
diff --git a/app/src/tasks/components/CreateTask.tsx b/app/src/tasks/components/CreateTask.tsx
index 2c64f61c7..57d25ed9b 100644
--- a/app/src/tasks/components/CreateTask.tsx
+++ b/app/src/tasks/components/CreateTask.tsx
@@ -215,19 +215,19 @@ export const CreateTask = ({ className, cta }: Props) => {
               {
                 value: TaskVisibility.PUBLIC,
                 label: "Öffentlich",
-                hint: "Diese Tasks können von jedem angenommen und erfüllt werden.",
+                hint: "Dieser Task kann von jedem angenommen und erfüllt werden.",
               },
               ...(showPersonalizedAndGroup
                 ? [
                     {
                       value: TaskVisibility.PERSONALIZED,
                       label: "Personalisiert",
-                      hint: "Diese Tasks werden an einen Citizen adressiert und müssen von diesem erfüllt werden. Werden mehrere Citizen angegeben, wird dieser Task für jeden Citizen dupliziert.",
+                      hint: "Dieser Task wird an einen Citizen adressiert und muss von diesem erfüllt werden. Werden mehrere Citizen angegeben, wird dieser Task für jeden Citizen dupliziert.",
                     },
                     {
                       value: TaskVisibility.GROUP,
                       label: "Gruppe",
-                      hint: "Diese Tasks werden an eine Gruppe von Citizen adressiert und müssen von dieser erfüllt werden.",
+                      hint: "Dieser Task wird an eine Gruppe von Citizen adressiert und muss von dieser erfüllt werden.",
                     },
                   ]
                 : []),
@@ -238,26 +238,26 @@ export const CreateTask = ({ className, cta }: Props) => {
           />
 
           {visibility === TaskVisibility.PUBLIC && (
-              <NumberInput
-                name="assignmentLimit"
-                label="Von wie vielen Citizen kann der Task angenommen werden?"
-                hint="optional"
-                defaultValue={
-                  state?.requestPayload?.has("assignmentLimit")
-                    ? (state.requestPayload.get("assignmentLimit") as string)
-                    : 1
-                }
-                min={1}
-                className="mt-4"
-              />
+            <NumberInput
+              name="assignmentLimit"
+              label="Von wie vielen Citizen kann der Task angenommen werden?"
+              hint="optional"
+              defaultValue={
+                state?.requestPayload?.has("assignmentLimit")
+                  ? (state.requestPayload.get("assignmentLimit") as string)
+                  : 1
+              }
+              min={1}
+              className="mt-4"
+            />
           )}
 
           {visibility === TaskVisibility.PERSONALIZED && (
-              <CitizenInput name="assignedToId" multiple className="mt-4" />
+            <CitizenInput name="assignedToId" multiple className="mt-4" />
           )}
 
           {visibility === TaskVisibility.GROUP && (
-              <CitizenInput name="assignedToId" multiple className="mt-4" />
+            <CitizenInput name="assignedToId" multiple className="mt-4" />
           )}
 
           <DateTimeInput


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

<details>
<summary>Files not reviewed (1)</summary>

* **app/prisma/migrations/20250413085117_add_tgask_visibility_group/migration.sql**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T09:46:41Z

Review comments:
diff_hunk: @@ -192,16 +195,39 @@ const Multiple = ({
   name,
   query,
   setQuery,
-  isPending,
+  isPendingAllCitizens,
   filteredCitizens,
   defaultValue,
   autoFocus,
 }: MultipleComponentProps) => {
+  const authentication = useAuthentication();
+  if (!authentication) throw new Error("Forbidden");
+
path: app/src/spynet/components/CitizenInput.tsx
user: login: Copilot
body: Throwing an error during render may crash the component unexpectedly. Consider using conditional rendering to display a fallback UI for unauthorized access.
```suggestion
  if (!authentication) {
    return <div className="text-red-500">Access Denied: You are not authorized to view this content.</div>;
  }
```
created_at: 2025-04-13T09:46:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,4 +32,57 @@ export const citizensRouter = createTRPCRouter({
       });
     }
   }),
+
+  getCitizensGroupedByVisibleRoles: protectedProcedure.query(async () => {
+    try {
+      const citizens = await prisma.entity.findMany({
+        where: {
+          roles: {
+            not: null,
+          },
+        },
+        orderBy: {
+          handle: "asc",
+        },
+      });
+
+      const visibleRoles = await getVisibleRoles();
+
+      const groupedCitizens = new Map<
+        string,
+        {
+          role: Role & {
+            icon: Upload | null;
+          };
+          citizens: Entity[];
+        }
+      >();
+
+      for (const citizen of citizens) {
+        const citizenRoleIds = citizen.roles?.split(",") ?? [];
+        for (const citizenRoleId of citizenRoleIds) {
path: app/src/server/api/routers/citizens.ts
user: login: Copilot
body: Consider trimming each role id after splitting to prevent potential mismatches due to unintended whitespace.
```suggestion
        const citizenRoleIds = citizen.roles?.split(",").map((id) => id.trim()) ?? [];
```
created_at: 2025-04-13T09:46:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-13T09:45:49Z
body: [vc]: #BGkeyelY/f9pXj0ALQl4Ri8pu3O1obpSAE/Enz9hFt4=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJzaW5pc3Rlci1pbmNvcnBvcmF0ZWQiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2ltb24ta25pdHRlbC9zaW5pc3Rlci1pbmNvcnBvcmF0ZWQvMjNMUVhtTnhUTnFWZWpEWUdiZjhBTTRlY3VmayIsInByZXZpZXdVcmwiOiJzaW5pc3Rlci1pbmNvcnBvcmF0ZWQtZ2l0LWRldmVsb3Atc2ltb24ta25pdHRlbC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6ImFwcCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **sinister-incorporated** | ✅ Ready ([Inspect](https://vercel.com/simon-knittel/sinister-incorporated/23LQXmNxTNqVejDYGbf8AM4ecufk)) | [Visit Preview](https://sinister-incorporated-git-develop-simon-knittel.vercel.app) | Apr 13, 2025 9:46am |


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-13T09:46:36Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=simonknittel_sinister-incorporated&pullRequest=1242) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=simonknittel_sinister-incorporated&pullRequest=1242&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=simonknittel_sinister-incorporated&pullRequest=1242&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=simonknittel_sinister-incorporated&pullRequest=1242&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=simonknittel_sinister-incorporated&pullRequest=1242&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=simonknittel_sinister-incorporated&pullRequest=1242&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=simonknittel_sinister-incorporated&pullRequest=1242)


author_association: NONE