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;
