This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-5893-8803d084cd68fc98c06c782aa7a56450e7ddc9ee
in repository https://gitbox.apache.org/repos/asf/texera.git

commit c0700ff24b16fb8779ca57ff161298b42a6556f0
Author: ali risheh <[email protected]>
AuthorDate: Tue Jun 23 10:37:55 2026 -0700

    feat: add user feedback dashboard and admin review (#5893)
    
    ### What changes were proposed in this PR?
    
    This PR adds a **user feedback** feature to the dashboard.
    
    - A **"Feedback"** item is added to the left sidebar, immediately after
    **"About"**, and is shown **only to logged-in users**. It routes to
    `/user/feedback` (behind the existing auth guard).
    - The feedback page lets a user **submit a free-text message** and shows
    a **table of their previously submitted feedback** (newest first).
    - The admin user list at `/dashboard/admin/user` gains a **"Feedbacks"**
    column next to **Quota**: a message icon that is **disabled when the
    user has no feedback** and **enabled with the feedback count shown as a
    badge** when they do. Clicking it opens that user's feedback in a modal
    (the same component reused in read-only mode).
    
    Backend:
    - New `feedback` table (added to `sql/texera_ddl.sql`, migration
    `sql/updates/25.sql`, and registered in `sql/changelog.xml`).
    - New `FeedbackResource`:
    - `POST /api/feedback` and `GET /api/feedback` — submit / list own
    feedback (any logged-in user).
    - `GET /api/feedback/counts` and `GET /api/feedback/user?user_id=` —
    per-user counts and per-user listing (**admin only**).
    
    This implements the simple feedback mechanism (Option 2) agreed in the
    design discussion.
    
    ### Any related issues, documentation, discussions?
    
    Closes #5894
    
    Proposed and agreed in discussion #5759.
    
    ### How was this PR tested?
    
    **Backend unit tests** — `FeedbackResourceSpec` (10 cases, using the
    embedded `MockTexeraDB`): persistence, newest-first ordering, whitespace
    trimming, empty/null rejection, per-user isolation, admin counts, and
    admin per-user listing.
    
    ```
    sbt "WorkflowExecutionService / testOnly 
org.apache.texera.web.resource.FeedbackResourceSpec"
    # Tests: succeeded 10, failed 0
    ```
    
    **Frontend unit tests** — `FeedbackService` (4), `FeedbackComponent` (5,
    both page and admin-modal modes), and the existing `AdminUserComponent`
    spec still passes with the new dependency.
    
    ```
    ng test --watch=false --include="**/feedback*.spec.ts" 
--include="**/admin-user*.spec.ts"
    # Test Files 3 passed (3) | Tests 10 passed (10)
    ```
    
    **Manual end-to-end** (local services + Postgres), logged in as the
    default admin:
    
    ```
    POST /api/feedback            -> 204
    GET  /api/feedback            -> 200  
[{"fid":1,"uid":1,"message":"...","creationTime":...}]
    GET  /api/feedback/counts     -> 200  [{"uid":1,"count":1}]
    GET  /api/feedback/user?...   -> 200
    POST /api/feedback (empty)    -> 400  "feedback message cannot be empty"
    ```
    
    UI flow verified locally: sidebar entry appears only when logged in,
    submit + own-feedback table work, and the admin column badge/disabled
    state and modal behave as described. _Reviewer note: before/after
    screenshots of the sidebar item, feedback page, and admin column to be
    attached._
    
    <img width="1847" height="965" alt="Screenshot from 2026-06-22 14-48-07"
    
src="https://github.com/user-attachments/assets/aaf972e0-e39f-48aa-af4f-859385867935";
    />
    <img width="1847" height="965" alt="Screenshot from 2026-06-22 14-47-40"
    
src="https://github.com/user-attachments/assets/8d223be0-b196-408b-bb77-4e97d6102807";
    />
    <img width="1847" height="965" alt="Screenshot from 2026-06-22 14-47-07"
    
src="https://github.com/user-attachments/assets/49580b32-8307-43f8-a9b1-65cd6f54577f";
    />
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../apache/texera/web/TexeraWebApplication.scala   |   1 +
 .../texera/web/resource/FeedbackResource.scala     | 132 +++++++++++++++++
 .../texera/web/resource/FeedbackResourceSpec.scala | 156 +++++++++++++++++++++
 frontend/src/app/app-routing.constant.ts           |   1 +
 frontend/src/app/app-routing.module.ts             |   5 +
 .../component/admin/user/admin-user.component.html |  19 +++
 .../component/admin/user/admin-user.component.ts   |  32 ++++-
 .../dashboard/component/dashboard.component.html   |  12 ++
 .../component/dashboard.component.spec.ts          |   4 +-
 .../app/dashboard/component/dashboard.component.ts |   2 +
 .../user/feedback/feedback.component.html          |  73 ++++++++++
 .../user/feedback/feedback.component.scss          |  47 +++++++
 .../user/feedback/feedback.component.spec.ts       | 123 ++++++++++++++++
 .../component/user/feedback/feedback.component.ts  | 129 +++++++++++++++++
 .../service/user/feedback/feedback.service.spec.ts |  84 +++++++++++
 .../service/user/feedback/feedback.service.ts      |  56 ++++++++
 .../src/app/dashboard/type/feedback.interface.ts   |  34 +++++
 sql/changelog.xml                                  |   5 +
 sql/texera_ddl.sql                                 |  10 ++
 sql/updates/25.sql                                 |  38 +++++
 20 files changed, 960 insertions(+), 3 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala 
b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index c93f75fe75..b4852abfb1 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -144,6 +144,7 @@ class TexeraWebApplication
     environment.jersey.register(classOf[AuthResource])
     environment.jersey.register(classOf[GoogleAuthResource])
     environment.jersey.register(classOf[UserConfigResource])
+    environment.jersey.register(classOf[FeedbackResource])
     environment.jersey.register(classOf[AdminUserResource])
     environment.jersey.register(classOf[PublicProjectResource])
     environment.jersey.register(classOf[WorkflowAccessResource])
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala 
b/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala
new file mode 100644
index 0000000000..8fc99b22c6
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/FeedbackResource.scala
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource
+
+import io.dropwizard.auth.Auth
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.jooq.generated.Tables.FEEDBACK
+import org.apache.texera.dao.jooq.generated.tables.pojos.Feedback
+import org.apache.texera.web.resource.FeedbackResource.{
+  FeedbackCount,
+  FeedbackEntry,
+  SubmitFeedbackRequest
+}
+import org.jooq.impl.DSL
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core._
+import scala.jdk.CollectionConverters._
+
+object FeedbackResource {
+  case class SubmitFeedbackRequest(message: String)
+  case class FeedbackEntry(fid: Integer, uid: Integer, message: String, 
creationTime: Long)
+  case class FeedbackCount(uid: Integer, count: Integer)
+}
+
+@Path("/feedback")
+@RolesAllowed(Array("REGULAR", "ADMIN"))
+class FeedbackResource {
+
+  /**
+    * Submit a new feedback message for the currently logged-in user. The fid
+    * (SERIAL) and creation_time (DEFAULT) columns are populated by the 
database.
+    */
+  @POST
+  @Consumes(Array(MediaType.APPLICATION_JSON))
+  def submitFeedback(request: SubmitFeedbackRequest, @Auth sessionUser: 
SessionUser): Unit = {
+    val message = Option(request).flatMap(r => 
Option(r.message)).map(_.trim).getOrElse("")
+    if (message.isEmpty) {
+      throw new BadRequestException("feedback message cannot be empty")
+    }
+    SqlServer
+      .getInstance()
+      .createDSLContext()
+      .insertInto(FEEDBACK, FEEDBACK.UID, FEEDBACK.MESSAGE)
+      .values(sessionUser.getUid, message)
+      .execute()
+  }
+
+  /**
+    * List the feedback submitted by the currently logged-in user, newest 
first.
+    */
+  @GET
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def listMyFeedback(@Auth sessionUser: SessionUser): List[FeedbackEntry] = {
+    fetchFeedbackByUid(sessionUser.getUid)
+  }
+
+  /**
+    * Admin only: number of feedback messages per user, for users who have
+    * submitted at least one. Users with zero feedback are omitted.
+    */
+  @GET
+  @Path("/counts")
+  @RolesAllowed(Array("ADMIN"))
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def feedbackCounts(): List[FeedbackCount] = {
+    val countField = DSL.count()
+    SqlServer
+      .getInstance()
+      .createDSLContext()
+      .select(FEEDBACK.UID, countField)
+      .from(FEEDBACK)
+      .groupBy(FEEDBACK.UID)
+      .fetch()
+      .asScala
+      .map(record => FeedbackCount(record.get(FEEDBACK.UID), 
record.get(countField)))
+      .toList
+  }
+
+  /**
+    * Admin only: list the feedback submitted by a specific user, newest first.
+    */
+  @GET
+  @Path("/user")
+  @RolesAllowed(Array("ADMIN"))
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def listUserFeedback(@QueryParam("user_id") userId: Integer): 
List[FeedbackEntry] = {
+    if (userId == null) {
+      throw new BadRequestException("user_id is required")
+    }
+    fetchFeedbackByUid(userId)
+  }
+
+  private def fetchFeedbackByUid(uid: Integer): List[FeedbackEntry] = {
+    SqlServer
+      .getInstance()
+      .createDSLContext()
+      .selectFrom(FEEDBACK)
+      .where(FEEDBACK.UID.eq(uid))
+      .orderBy(FEEDBACK.CREATION_TIME.desc())
+      .fetchInto(classOf[Feedback])
+      .asScala
+      .map(feedback =>
+        FeedbackEntry(
+          feedback.getFid,
+          feedback.getUid,
+          feedback.getMessage,
+          feedback.getCreationTime.getTime
+        )
+      )
+      .toList
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala
new file mode 100644
index 0000000000..9b081f7efa
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/FeedbackResourceSpec.scala
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource
+
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.FEEDBACK
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.web.resource.FeedbackResource.SubmitFeedbackRequest
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.util.UUID
+import javax.ws.rs.BadRequestException
+
+class FeedbackResourceSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with MockTexeraDB {
+
+  private val testUid = 9000 + scala.util.Random.nextInt(1000)
+  private val otherUid = testUid + 1
+  private var sessionUser: SessionUser = _
+  private var otherSessionUser: SessionUser = _
+  private val resource = new FeedbackResource
+
+  private def makeUser(uid: Int, name: String): User = {
+    val user = new User
+    user.setUid(uid)
+    user.setName(name)
+    user.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    user.setPassword("password")
+    user.setRole(UserRoleEnum.REGULAR)
+    user
+  }
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+    val userDao = new UserDao(getDSLContext.configuration())
+    val testUser = makeUser(testUid, "feedback_spec_user")
+    val otherUser = makeUser(otherUid, "feedback_spec_other_user")
+    userDao.insert(testUser)
+    userDao.insert(otherUser)
+    sessionUser = new SessionUser(testUser)
+    otherSessionUser = new SessionUser(otherUser)
+  }
+
+  override protected def afterAll(): Unit = shutdownDB()
+
+  private def clearFeedback(): Unit = {
+    getDSLContext.deleteFrom(FEEDBACK).execute()
+  }
+
+  "FeedbackResource" should "persist a submitted feedback and return it for 
the same user" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("the editor is great"), 
sessionUser)
+
+    val feedback = resource.listMyFeedback(sessionUser)
+    feedback should have size 1
+    feedback.head.message shouldBe "the editor is great"
+    feedback.head.uid shouldBe testUid
+    feedback.head.fid should not be null
+    feedback.head.creationTime should be > 0L
+  }
+
+  it should "return feedback newest first" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("first message"), 
sessionUser)
+    Thread.sleep(1000) // creation_time has 1-second resolution
+    resource.submitFeedback(SubmitFeedbackRequest("second message"), 
sessionUser)
+
+    val messages = resource.listMyFeedback(sessionUser).map(_.message)
+    messages shouldBe List("second message", "first message")
+  }
+
+  it should "trim surrounding whitespace from the feedback message" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("   padded message   "), 
sessionUser)
+    resource.listMyFeedback(sessionUser).head.message shouldBe "padded message"
+  }
+
+  it should "reject an empty or whitespace-only feedback message" in {
+    clearFeedback()
+    an[BadRequestException] should be thrownBy
+      resource.submitFeedback(SubmitFeedbackRequest("   "), sessionUser)
+    an[BadRequestException] should be thrownBy
+      resource.submitFeedback(SubmitFeedbackRequest(""), sessionUser)
+    resource.listMyFeedback(sessionUser) shouldBe empty
+  }
+
+  it should "reject a null message body" in {
+    clearFeedback()
+    an[BadRequestException] should be thrownBy
+      resource.submitFeedback(SubmitFeedbackRequest(null), sessionUser)
+  }
+
+  it should "isolate feedback between users" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("from test user"), 
sessionUser)
+    resource.submitFeedback(SubmitFeedbackRequest("from other user a"), 
otherSessionUser)
+    resource.submitFeedback(SubmitFeedbackRequest("from other user b"), 
otherSessionUser)
+
+    resource.listMyFeedback(sessionUser).map(_.message) shouldBe List("from 
test user")
+    resource.listMyFeedback(otherSessionUser) should have size 2
+  }
+
+  "feedbackCounts" should "report per-user counts only for users with 
feedback" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("a"), sessionUser)
+    resource.submitFeedback(SubmitFeedbackRequest("b"), sessionUser)
+    resource.submitFeedback(SubmitFeedbackRequest("c"), otherSessionUser)
+
+    val counts = resource.feedbackCounts().map(c => c.uid.intValue() -> 
c.count.intValue()).toMap
+    counts(testUid) shouldBe 2
+    counts(otherUid) shouldBe 1
+  }
+
+  it should "return an empty list when nobody has submitted feedback" in {
+    clearFeedback()
+    resource.feedbackCounts() shouldBe empty
+  }
+
+  "listUserFeedback" should "return a specific user's feedback for admins" in {
+    clearFeedback()
+    resource.submitFeedback(SubmitFeedbackRequest("target user feedback"), 
otherSessionUser)
+    resource.submitFeedback(SubmitFeedbackRequest("noise"), sessionUser)
+
+    val feedback = resource.listUserFeedback(otherUid)
+    feedback.map(_.message) shouldBe List("target user feedback")
+  }
+
+  it should "reject a missing user_id" in {
+    an[BadRequestException] should be thrownBy resource.listUserFeedback(null)
+  }
+}
diff --git a/frontend/src/app/app-routing.constant.ts 
b/frontend/src/app/app-routing.constant.ts
index e0b2c9eab0..f5a0130039 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -38,6 +38,7 @@ export const USER_COMPUTING_UNIT = `${USER}/compute`;
 export const USER_PYTHON_VENV = `${USER}/python-venv`;
 export const USER_QUOTA = `${USER}/quota`;
 export const USER_DISCUSSION = `${USER}/discussion`;
+export const USER_FEEDBACK = `${USER}/feedback`;
 
 export const ADMIN = "/admin";
 export const ADMIN_USER = `${ADMIN}/user`;
diff --git a/frontend/src/app/app-routing.module.ts 
b/frontend/src/app/app-routing.module.ts
index 78ccf0232c..58f9014300 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -34,6 +34,7 @@ import { AdminExecutionComponent } from 
"./dashboard/component/admin/execution/a
 import { AdminGuardService } from 
"./dashboard/service/admin/guard/admin-guard.service";
 import { SearchComponent } from 
"./dashboard/component/user/search/search.component";
 import { FlarumComponent } from 
"./dashboard/component/user/flarum/flarum.component";
+import { FeedbackComponent } from 
"./dashboard/component/user/feedback/feedback.component";
 import { AdminGmailComponent } from 
"./dashboard/component/admin/gmail/admin-gmail.component";
 import { DatasetDetailComponent } from 
"./dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component";
 import { UserDatasetComponent } from 
"./dashboard/component/user/user-dataset/user-dataset.component";
@@ -141,6 +142,10 @@ routes.push({
           path: "discussion",
           component: FlarumComponent,
         },
+        {
+          path: "feedback",
+          component: FeedbackComponent,
+        },
       ],
     },
     {
diff --git 
a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html 
b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
index f39ad7b20a..4070cb9308 100644
--- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
+++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
@@ -106,6 +106,7 @@
         User Role
       </th>
       <th>Quota</th>
+      <th>Feedbacks</th>
       <th
         [nzSortFn]="sortByAccountCreation"
         [nzSortDirections]="['ascend', 'descend']">
@@ -314,6 +315,24 @@
             nzType="dashboard"></i>
         </button>
       </td>
+      <td>
+        <button
+          (click)="clickToViewFeedbacks(user.uid)"
+          [disabled]="getFeedbackCount(user.uid) === 0"
+          nz-button
+          [nz-tooltip]="getFeedbackCount(user.uid) === 0 ? 'no feedback 
submitted' : 'view feedback'"
+          type="button">
+          <nz-badge
+            [nzCount]="getFeedbackCount(user.uid)"
+            [nzOverflowCount]="99"
+            nzSize="small">
+            <i
+              nz-icon
+              nzTheme="outline"
+              nzType="message"></i>
+          </nz-badge>
+        </button>
+      </td>
       <td>
         <ng-container *ngIf="getAccountCreation(user) as ac; else noAC">
           {{ ac | date:'MM/dd/y, h:mm a' }}
diff --git 
a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts 
b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
index 090032da2a..0d7f620480 100644
--- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
+++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts
@@ -37,6 +37,9 @@ import { AdminUserService } from 
"../../../service/admin/user/admin-user.service
 import { MilliSecond, Role, User } from "../../../../common/type/user";
 import { UserService } from "../../../../common/service/user/user.service";
 import { UserQuotaComponent } from 
"../../user/user-quota/user-quota.component";
+import { FeedbackComponent } from "../../user/feedback/feedback.component";
+import { FeedbackService } from 
"../../../service/user/feedback/feedback.service";
+import { NzBadgeComponent } from "ng-zorro-antd/badge";
 import { GuiConfigService } from 
"../../../../common/service/gui-config.service";
 import { replaceOneImmutable } from "../../../../common/util/array-utils";
 import { NzCardComponent } from "ng-zorro-antd/card";
@@ -82,6 +85,7 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
     NzSelectComponent,
     NzOptionComponent,
     NzTooltipDirective,
+    NzBadgeComponent,
     DatePipe,
   ],
 })
@@ -101,6 +105,7 @@ export class AdminUserComponent implements OnInit {
   commentSearchVisible = false;
   listOfDisplayUser = [...this.userList];
   currentUid: number | undefined = 0;
+  feedbackCounts = new Map<number, number>();
 
   @ViewChild("nameInput") nameInputRef?: ElementRef<HTMLInputElement>;
   @ViewChild("emailInput") emailInputRef?: ElementRef<HTMLInputElement>;
@@ -111,7 +116,8 @@ export class AdminUserComponent implements OnInit {
     private userService: UserService,
     private modalService: NzModalService,
     private messageService: NzMessageService,
-    private config: GuiConfigService
+    private config: GuiConfigService,
+    private feedbackService: FeedbackService
   ) {
     this.currentUid = this.userService.getCurrentUser()?.uid;
   }
@@ -124,6 +130,30 @@ export class AdminUserComponent implements OnInit {
         this.userList = userList;
         this.reset();
       });
+    this.loadFeedbackCounts();
+  }
+
+  loadFeedbackCounts(): void {
+    this.feedbackService
+      .getFeedbackCounts()
+      .pipe(untilDestroyed(this))
+      .subscribe(counts => {
+        this.feedbackCounts = new Map(counts.map(c => [c.uid, c.count]));
+      });
+  }
+
+  getFeedbackCount(uid: number): number {
+    return this.feedbackCounts.get(uid) ?? 0;
+  }
+
+  clickToViewFeedbacks(uid: number): void {
+    this.modalService.create({
+      nzContent: FeedbackComponent,
+      nzData: { uid: uid },
+      nzFooter: null,
+      nzWidth: "60%",
+      nzCentered: true,
+    });
   }
 
   public updateRole(user: User, role: Role): void {
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html 
b/frontend/src/app/dashboard/component/dashboard.component.html
index c01def869b..f9336600bf 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -211,6 +211,18 @@
           nzType="info-circle"></span>
         <span>About</span>
       </li>
+
+      <li
+        *ngIf="isLogin"
+        nz-menu-item
+        nz-tooltip
+        nzTooltipPlacement="right"
+        [routerLink]="USER_FEEDBACK">
+        <span
+          nz-icon
+          nzType="message"></span>
+        <span>Feedback</span>
+      </li>
     </ul>
     <span id="build-number">Build: {{ buildNumber }}</span>
     <span
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts 
b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index 10352e3b57..cad7ed91be 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -283,7 +283,7 @@ describe("DashboardComponent", () => {
     };
     fixture.detectChanges();
 
-    // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link 
= 12
-    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(12);
+    // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link 
+ 1 feedback link = 13
+    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(13);
   });
 });
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts 
b/frontend/src/app/dashboard/component/dashboard.component.ts
index 01c05b0e52..88dc04bb69 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -41,6 +41,7 @@ import {
   USER_PYTHON_VENV,
   USER_QUOTA,
   USER_WORKFLOW,
+  USER_FEEDBACK,
 } from "../../app-routing.constant";
 import { Version } from "../../../environments/version";
 import { SidebarTabs } from "../../common/type/gui-config";
@@ -113,6 +114,7 @@ export class DashboardComponent implements OnInit {
   protected readonly USER_PYTHON_VENV = USER_PYTHON_VENV;
   protected readonly USER_QUOTA = USER_QUOTA;
   protected readonly USER_DISCUSSION = USER_DISCUSSION;
+  protected readonly USER_FEEDBACK = USER_FEEDBACK;
   protected readonly ADMIN_USER = ADMIN_USER;
   protected readonly ADMIN_GMAIL = ADMIN_GMAIL;
   protected readonly ADMIN_EXECUTION = ADMIN_EXECUTION;
diff --git 
a/frontend/src/app/dashboard/component/user/feedback/feedback.component.html 
b/frontend/src/app/dashboard/component/user/feedback/feedback.component.html
new file mode 100644
index 0000000000..72da7bb214
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.html
@@ -0,0 +1,73 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<div class="feedback-container">
+  <!-- Submit box: only when the user is viewing their own feedback -->
+  <nz-card
+    *ngIf="!isAdminView"
+    class="feedback-submit-card"
+    nzTitle="Send us your feedback">
+    <p class="feedback-hint">
+      Tell us what you like, what is broken, or what you would like to see 
next. Your feedback goes straight to the
+      Texera admins.
+    </p>
+    <textarea
+      nz-input
+      [(ngModel)]="newFeedback"
+      [disabled]="submitting"
+      placeholder="Type your feedback here..."
+      rows="4"
+      class="feedback-textarea"></textarea>
+    <button
+      nz-button
+      nzType="primary"
+      class="feedback-submit-button"
+      [nzLoading]="submitting"
+      [disabled]="newFeedback.trim().length === 0"
+      (click)="submitFeedback()">
+      <span
+        nz-icon
+        nzType="send"></span>
+      Submit Feedback
+    </button>
+  </nz-card>
+
+  <!-- Previous feedback table -->
+  <nz-card [nzTitle]="isAdminView ? 'User feedback' : 'Your previous 
feedback'">
+    <nz-table
+      #feedbackTable
+      [nzData]="[...feedbackList]"
+      [nzShowPagination]="feedbackList.length > 10"
+      [nzPageSize]="10"
+      nzSize="small">
+      <thead>
+        <tr>
+          <th nzWidth="220px">Submitted</th>
+          <th>Feedback</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr *ngFor="let feedback of feedbackTable.data">
+          <td>{{ feedback.creationTime | date: "MM/dd/y, h:mm a" }}</td>
+          <td class="feedback-message">{{ feedback.message }}</td>
+        </tr>
+      </tbody>
+    </nz-table>
+  </nz-card>
+</div>
diff --git 
a/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss 
b/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss
new file mode 100644
index 0000000000..95a8fc6981
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.scss
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.feedback-container {
+  max-width: 900px;
+  margin: 0 auto;
+  padding: 24px;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.feedback-hint {
+  color: rgba(0, 0, 0, 0.55);
+  margin-bottom: 12px;
+}
+
+.feedback-textarea {
+  margin-bottom: 16px;
+}
+
+.feedback-submit-button {
+  span[nz-icon] {
+    margin-right: 4px;
+  }
+}
+
+.feedback-message {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
diff --git 
a/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts 
b/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts
new file mode 100644
index 0000000000..915ad9dcba
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/feedback/feedback.component.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { of } from "rxjs";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { NzMessageService } from "ng-zorro-antd/message";
+
+import { FeedbackComponent } from "./feedback.component";
+import { FeedbackService } from 
"../../../service/user/feedback/feedback.service";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { Feedback } from "../../../type/feedback.interface";
+
+function makeFeedbackServiceSpy() {
+  return {
+    getMyFeedback: vi.fn().mockReturnValue(of([] as Feedback[])),
+    getUserFeedback: vi.fn().mockReturnValue(of([] as Feedback[])),
+    submitFeedback: vi.fn().mockReturnValue(of(undefined)),
+    getFeedbackCounts: vi.fn().mockReturnValue(of([])),
+  };
+}
+
+function makeMessageSpy() {
+  return { success: vi.fn(), error: vi.fn(), warning: vi.fn() };
+}
+
+describe("FeedbackComponent", () => {
+  describe("own-feedback (page) mode", () => {
+    let component: FeedbackComponent;
+    let fixture: ComponentFixture<FeedbackComponent>;
+    let feedbackSpy: ReturnType<typeof makeFeedbackServiceSpy>;
+    let messageSpy: ReturnType<typeof makeMessageSpy>;
+
+    beforeEach(async () => {
+      feedbackSpy = makeFeedbackServiceSpy();
+      messageSpy = makeMessageSpy();
+      await TestBed.configureTestingModule({
+        imports: [FeedbackComponent, HttpClientTestingModule],
+        providers: [
+          { provide: FeedbackService, useValue: feedbackSpy },
+          { provide: NzMessageService, useValue: messageSpy },
+          ...commonTestProviders,
+        ],
+      }).compileComponents();
+      fixture = TestBed.createComponent(FeedbackComponent);
+      component = fixture.componentInstance;
+      fixture.detectChanges();
+    });
+
+    it("creates and is not in admin view", () => {
+      expect(component).toBeTruthy();
+      expect(component.isAdminView).toBe(false);
+    });
+
+    it("loads the current user's own feedback on init", () => {
+      expect(feedbackSpy.getMyFeedback).toHaveBeenCalled();
+      expect(feedbackSpy.getUserFeedback).not.toHaveBeenCalled();
+    });
+
+    it("does not submit empty or whitespace-only feedback", () => {
+      component.newFeedback = "   ";
+      component.submitFeedback();
+      expect(feedbackSpy.submitFeedback).not.toHaveBeenCalled();
+      expect(messageSpy.warning).toHaveBeenCalled();
+    });
+
+    it("submits trimmed feedback, clears the box, and reloads on success", () 
=> {
+      feedbackSpy.getMyFeedback.mockClear();
+      component.newFeedback = "  great tool  ";
+      component.submitFeedback();
+      expect(feedbackSpy.submitFeedback).toHaveBeenCalledWith("great tool");
+      expect(component.newFeedback).toBe("");
+      expect(messageSpy.success).toHaveBeenCalled();
+      expect(feedbackSpy.getMyFeedback).toHaveBeenCalled();
+    });
+  });
+
+  describe("admin (modal) mode", () => {
+    let component: FeedbackComponent;
+    let fixture: ComponentFixture<FeedbackComponent>;
+    let feedbackSpy: ReturnType<typeof makeFeedbackServiceSpy>;
+
+    beforeEach(async () => {
+      feedbackSpy = makeFeedbackServiceSpy();
+      await TestBed.configureTestingModule({
+        imports: [FeedbackComponent, HttpClientTestingModule],
+        providers: [
+          { provide: FeedbackService, useValue: feedbackSpy },
+          { provide: NzMessageService, useValue: makeMessageSpy() },
+          { provide: NZ_MODAL_DATA, useValue: { uid: 42 } },
+          ...commonTestProviders,
+        ],
+      }).compileComponents();
+      fixture = TestBed.createComponent(FeedbackComponent);
+      component = fixture.componentInstance;
+      fixture.detectChanges();
+    });
+
+    it("is in admin view and loads the target user's feedback", () => {
+      expect(component.isAdminView).toBe(true);
+      expect(component.adminUid).toBe(42);
+      expect(feedbackSpy.getUserFeedback).toHaveBeenCalledWith(42);
+      expect(feedbackSpy.getMyFeedback).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git 
a/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts 
b/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts
new file mode 100644
index 0000000000..e4e114a939
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/feedback/feedback.component.ts
@@ -0,0 +1,129 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, inject, OnInit } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { NgFor, NgIf, DatePipe } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import {
+  NzTableComponent,
+  NzTheadComponent,
+  NzTrDirective,
+  NzTableCellDirective,
+  NzThMeasureDirective,
+  NzTbodyComponent,
+} from "ng-zorro-antd/table";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { FeedbackService } from 
"../../../service/user/feedback/feedback.service";
+import { Feedback } from "../../../type/feedback.interface";
+
+/**
+ * Feedback view. Used in two modes:
+ *  - As a routed page (no modal data): the logged-in user submits feedback and
+ *    sees a table of their own previous feedback.
+ *  - As modal content with `{ uid }` injected via NZ_MODAL_DATA: an admin 
views
+ *    a specific user's feedback read-only (no submit box).
+ */
+@UntilDestroy()
+@Component({
+  selector: "texera-feedback",
+  templateUrl: "./feedback.component.html",
+  styleUrls: ["./feedback.component.scss"],
+  imports: [
+    NgFor,
+    NgIf,
+    DatePipe,
+    FormsModule,
+    NzCardComponent,
+    NzButtonComponent,
+    NzWaveDirective,
+    NzInputDirective,
+    NzIconDirective,
+    NzTableComponent,
+    NzTheadComponent,
+    NzTrDirective,
+    NzTableCellDirective,
+    NzThMeasureDirective,
+    NzTbodyComponent,
+  ],
+})
+export class FeedbackComponent implements OnInit {
+  // When set, the component is showing another user's feedback (admin modal 
view).
+  readonly adminUid: number | undefined = inject(NZ_MODAL_DATA, { optional: 
true })?.uid;
+  newFeedback: string = "";
+  submitting: boolean = false;
+  feedbackList: ReadonlyArray<Feedback> = [];
+
+  constructor(
+    private feedbackService: FeedbackService,
+    private messageService: NzMessageService
+  ) {}
+
+  get isAdminView(): boolean {
+    return this.adminUid !== undefined;
+  }
+
+  ngOnInit(): void {
+    this.loadFeedback();
+  }
+
+  loadFeedback(): void {
+    const request$ = this.isAdminView
+      ? this.feedbackService.getUserFeedback(this.adminUid as number)
+      : this.feedbackService.getMyFeedback();
+    request$.pipe(untilDestroyed(this)).subscribe({
+      next: feedbackList => (this.feedbackList = feedbackList),
+      error: (err: unknown) => 
this.messageService.error(this.extractError(err)),
+    });
+  }
+
+  submitFeedback(): void {
+    const message = this.newFeedback.trim();
+    if (message.length === 0) {
+      this.messageService.warning("Feedback cannot be empty.");
+      return;
+    }
+    this.submitting = true;
+    this.feedbackService
+      .submitFeedback(message)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => {
+          this.submitting = false;
+          this.newFeedback = "";
+          this.messageService.success("Thank you for your feedback!");
+          this.loadFeedback();
+        },
+        error: (err: unknown) => {
+          this.submitting = false;
+          this.messageService.error(this.extractError(err));
+        },
+      });
+  }
+
+  private extractError(err: unknown): string {
+    return (err as any)?.error?.message || (err as Error)?.message || "An 
unexpected error occurred.";
+  }
+}
diff --git 
a/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts 
b/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts
new file mode 100644
index 0000000000..37a4217b8c
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/feedback/feedback.service.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+
+import { FeedbackService } from "./feedback.service";
+import { AppSettings } from "../../../../common/app-setting";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { Feedback, FeedbackCount } from "../../../type/feedback.interface";
+
+const API = "api";
+
+describe("FeedbackService", () => {
+  let service: FeedbackService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [FeedbackService, ...commonTestProviders],
+    });
+    service = TestBed.inject(FeedbackService);
+    http = TestBed.inject(HttpTestingController);
+    vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+  });
+
+  afterEach(() => {
+    http.verify();
+  });
+
+  it("submitFeedback POSTs the message to /feedback", () => {
+    service.submitFeedback("hello world").subscribe();
+    const req = http.expectOne(`${API}/feedback`);
+    expect(req.request.method).toBe("POST");
+    expect(req.request.body).toEqual({ message: "hello world" });
+    req.flush(null);
+  });
+
+  it("getMyFeedback GETs /feedback and returns the list", async () => {
+    const expected: ReadonlyArray<Feedback> = [{ fid: 1, uid: 7, message: "m", 
creationTime: 123 }];
+    const pending = firstValueFrom(service.getMyFeedback());
+    const req = http.expectOne(`${API}/feedback`);
+    expect(req.request.method).toBe("GET");
+    req.flush(expected);
+    expect(await pending).toEqual(expected);
+  });
+
+  it("getFeedbackCounts GETs /feedback/counts", async () => {
+    const expected: ReadonlyArray<FeedbackCount> = [{ uid: 7, count: 3 }];
+    const pending = firstValueFrom(service.getFeedbackCounts());
+    const req = http.expectOne(`${API}/feedback/counts`);
+    expect(req.request.method).toBe("GET");
+    req.flush(expected);
+    expect(await pending).toEqual(expected);
+  });
+
+  it("getUserFeedback GETs /feedback/user with a user_id query param", async 
() => {
+    const expected: ReadonlyArray<Feedback> = [{ fid: 2, uid: 42, message: 
"x", creationTime: 9 }];
+    const pending = firstValueFrom(service.getUserFeedback(42));
+    const req = http.expectOne(r => r.url === `${API}/feedback/user`);
+    expect(req.request.method).toBe("GET");
+    expect(req.request.params.get("user_id")).toBe("42");
+    req.flush(expected);
+    expect(await pending).toEqual(expected);
+  });
+});
diff --git 
a/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts 
b/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts
new file mode 100644
index 0000000000..e6955425b5
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/feedback/feedback.service.ts
@@ -0,0 +1,56 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient, HttpParams } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+import { Feedback, FeedbackCount } from "../../../type/feedback.interface";
+
+export const FEEDBACK_BASE_URL = `${AppSettings.getApiEndpoint()}/feedback`;
+export const FEEDBACK_COUNTS_URL = `${FEEDBACK_BASE_URL}/counts`;
+export const FEEDBACK_USER_URL = `${FEEDBACK_BASE_URL}/user`;
+
+@Injectable({
+  providedIn: "root",
+})
+export class FeedbackService {
+  constructor(private http: HttpClient) {}
+
+  /** Submit a new feedback message for the current user. */
+  public submitFeedback(message: string): Observable<void> {
+    return this.http.post<void>(`${FEEDBACK_BASE_URL}`, { message });
+  }
+
+  /** List the current user's own feedback, newest first. */
+  public getMyFeedback(): Observable<ReadonlyArray<Feedback>> {
+    return this.http.get<ReadonlyArray<Feedback>>(`${FEEDBACK_BASE_URL}`);
+  }
+
+  /** Admin only: feedback counts per user (only users with >= 1 feedback). */
+  public getFeedbackCounts(): Observable<ReadonlyArray<FeedbackCount>> {
+    return 
this.http.get<ReadonlyArray<FeedbackCount>>(`${FEEDBACK_COUNTS_URL}`);
+  }
+
+  /** Admin only: list the feedback submitted by a specific user, newest 
first. */
+  public getUserFeedback(uid: number): Observable<ReadonlyArray<Feedback>> {
+    const params = new HttpParams().set("user_id", uid.toString());
+    return this.http.get<ReadonlyArray<Feedback>>(`${FEEDBACK_USER_URL}`, { 
params });
+  }
+}
diff --git a/frontend/src/app/dashboard/type/feedback.interface.ts 
b/frontend/src/app/dashboard/type/feedback.interface.ts
new file mode 100644
index 0000000000..d045fb0c5a
--- /dev/null
+++ b/frontend/src/app/dashboard/type/feedback.interface.ts
@@ -0,0 +1,34 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** A single feedback message owned by a user. `creationTime` is epoch 
milliseconds. */
+export interface Feedback
+  extends Readonly<{
+    fid: number;
+    uid: number;
+    message: string;
+    creationTime: number;
+  }> {}
+
+/** Number of feedback messages submitted by a single user. */
+export interface FeedbackCount
+  extends Readonly<{
+    uid: number;
+    count: number;
+  }> {}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 39119f538b..e216caf3d0 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -33,6 +33,11 @@
         <sqlFile path="sql/updates/24.sql"/>
     </changeSet>
 
+    <!-- Add feedback table -->
+    <changeSet id="25" author="arisheh">
+        <sqlFile path="sql/updates/25.sql"/>
+    </changeSet>
+
     <!-- example changeSet
     <changeSet id="1" author="author">
         <sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index 26b009e420..9728829851 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS user_config
     FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
     );
 
+-- feedback
+CREATE TABLE IF NOT EXISTS feedback
+(
+    fid           SERIAL PRIMARY KEY,
+    uid           INT NOT NULL,
+    message       TEXT NOT NULL,
+    creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
+    );
+
 -- workflow
 CREATE TABLE IF NOT EXISTS workflow
 (
diff --git a/sql/updates/25.sql b/sql/updates/25.sql
new file mode 100644
index 0000000000..731ae3f468
--- /dev/null
+++ b/sql/updates/25.sql
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+-- Adds the feedback table, used to persist free-text feedback messages
+-- submitted by users from the dashboard. Each row is one feedback message
+-- owned by a user; deleting the user cascades to their feedback.
+CREATE TABLE IF NOT EXISTS feedback
+(
+    fid           SERIAL PRIMARY KEY,
+    uid           INT NOT NULL,
+    message       TEXT NOT NULL,
+    creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
+);
+
+COMMIT;

Reply via email to