mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Add dismiss, id and clear API endpoints for notifications
This commit is contained in:
parent
47133ac3fe
commit
0ca8000186
|
|
@ -32,16 +32,16 @@ import {
|
|||
emojiToStatus,
|
||||
instance,
|
||||
type like,
|
||||
notification,
|
||||
status,
|
||||
statusToMentions,
|
||||
user,
|
||||
notification,
|
||||
} from "~drizzle/schema";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Note } from "~types/lysand/Object";
|
||||
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
|
||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||
import { applicationToAPI, type Application } from "./Application";
|
||||
import { type Application, applicationToAPI } from "./Application";
|
||||
import {
|
||||
attachmentFromLysand,
|
||||
attachmentToAPI,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||
import type { Source as APISource } from "~types/mastodon/source";
|
||||
import type { Application } from "./Application";
|
||||
import {
|
||||
type EmojiWithInstance,
|
||||
emojiToAPI,
|
||||
|
|
@ -28,7 +29,6 @@ import { objectToInboxRequest } from "./Federation";
|
|||
import { addInstanceIfNotExists } from "./Instance";
|
||||
import { createNewRelationship } from "./Relationship";
|
||||
import type { Token } from "./Token";
|
||||
import type { Application } from "./Application";
|
||||
|
||||
export type User = InferSelectModel<typeof user> & {
|
||||
endpoints?: Partial<{
|
||||
|
|
|
|||
1
drizzle/0008_flawless_brother_voodoo.sql
Normal file
1
drizzle/0008_flawless_brother_voodoo.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "Notification" ADD COLUMN "dismissed" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load diff
1582
drizzle/meta/0008_snapshot.json
Normal file
1582
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,62 +1,69 @@
|
|||
{
|
||||
"version": "5",
|
||||
"dialect": "pg",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1712805159664,
|
||||
"tag": "0000_illegal_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1713055774123,
|
||||
"tag": "0001_salty_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1713056370431,
|
||||
"tag": "0002_stiff_ares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1713056528340,
|
||||
"tag": "0003_spicy_arachne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1713056712218,
|
||||
"tag": "0004_burly_lockjaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1713056917973,
|
||||
"tag": "0005_sleepy_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1713057159867,
|
||||
"tag": "0006_messy_network",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1713227918208,
|
||||
"tag": "0007_naive_sleeper",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "5",
|
||||
"dialect": "pg",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1712805159664,
|
||||
"tag": "0000_illegal_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1713055774123,
|
||||
"tag": "0001_salty_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1713056370431,
|
||||
"tag": "0002_stiff_ares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1713056528340,
|
||||
"tag": "0003_spicy_arachne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1713056712218,
|
||||
"tag": "0004_burly_lockjaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1713056917973,
|
||||
"tag": "0005_sleepy_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1713057159867,
|
||||
"tag": "0006_messy_network",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1713227918208,
|
||||
"tag": "0007_naive_sleeper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1713246700119,
|
||||
"tag": "0008_flawless_brother_voodoo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -195,6 +195,7 @@ export const notification = pgTable("Notification", {
|
|||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
dismissed: boolean("dismissed").default(false).notNull(),
|
||||
});
|
||||
|
||||
export const status = pgTable(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { join } from "node:path";
|
||||
import { redirect } from "@response";
|
||||
import { config } from "config-manager";
|
||||
import { retrieveUserFromToken, userToAPI } from "~database/entities/User";
|
||||
import type { LogManager, MultiLogManager } from "~packages/log-manager";
|
||||
import { languages } from "./glitch-languages";
|
||||
import { redirect } from "@response";
|
||||
import { retrieveUserFromToken, userToAPI } from "~database/entities/User";
|
||||
|
||||
export const handleGlitchRequest = async (
|
||||
req: Request,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import { apiRoute, applyConfig } from "@api";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~database/entities/Token";
|
||||
import { findFirstUser } from "~database/entities/User";
|
||||
import { db } from "~drizzle/db";
|
||||
import { token } from "~drizzle/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import { apiRoute, applyConfig } from "@api";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~database/entities/Token";
|
||||
import { findFirstUser } from "~database/entities/User";
|
||||
import { db } from "~drizzle/db";
|
||||
import { token } from "~drizzle/schema";
|
||||
import { z } from "zod";
|
||||
import { config } from "~packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
|
|
|
|||
93
server/api/api/v1/notifications/[id]/dismiss.test.ts
Normal file
93
server/api/api/v1/notifications/[id]/dismiss.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
deleteOldTestUsers,
|
||||
getTestUsers,
|
||||
sendTestRequest,
|
||||
} from "~tests/utils";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import { meta } from "./dismiss";
|
||||
|
||||
await deleteOldTestUsers();
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: APINotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await fetch(
|
||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
notifications = await fetch(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/:id/dismiss
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should dismiss notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", notifications[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not display dismissed notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const output = await response.json();
|
||||
|
||||
expect(output.length).toBe(0);
|
||||
});
|
||||
});
|
||||
37
server/api/api/v1/notifications/[id]/dismiss.ts
Normal file
37
server/api/api/v1/notifications/[id]/dismiss.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { notification } from "~drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/notifications/:id/dismiss",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:notifications"],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||
const id = matchedRoute.params.id;
|
||||
if (!id.match(idValidator)) {
|
||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||
}
|
||||
|
||||
const { user } = extraData.auth;
|
||||
if (!user) return errorResponse("Unauthorized", 401);
|
||||
|
||||
await db
|
||||
.update(notification)
|
||||
.set({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(eq(notification.id, id));
|
||||
|
||||
return jsonResponse({});
|
||||
});
|
||||
117
server/api/api/v1/notifications/[id]/index.test.ts
Normal file
117
server/api/api/v1/notifications/[id]/index.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
deleteOldTestUsers,
|
||||
getTestUsers,
|
||||
sendTestRequest,
|
||||
} from "~tests/utils";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import { meta } from "./index";
|
||||
|
||||
await deleteOldTestUsers();
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: APINotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await fetch(
|
||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
notifications = await fetch(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/:id
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if ID is invalid", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", "invalid"),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return 404 if notification not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", notifications[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const notification = (await response.json()) as APINotification;
|
||||
|
||||
expect(notification).toBeDefined();
|
||||
expect(notification.id).toBe(notifications[0].id);
|
||||
expect(notification.type).toBe("follow");
|
||||
expect(notification.account).toBeDefined();
|
||||
expect(notification.account?.id).toBe(users[1].id);
|
||||
});
|
||||
});
|
||||
37
server/api/api/v1/notifications/[id]/index.ts
Normal file
37
server/api/api/v1/notifications/[id]/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { findManyNotifications } from "~database/entities/Notification";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/notifications/:id",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:notifications"],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||
const id = matchedRoute.params.id;
|
||||
if (!id.match(idValidator)) {
|
||||
return errorResponse("Invalid ID, must be of type UUIDv7", 404);
|
||||
}
|
||||
|
||||
const { user } = extraData.auth;
|
||||
if (!user) return errorResponse("Unauthorized", 401);
|
||||
|
||||
const notification = (
|
||||
await findManyNotifications({
|
||||
where: (notification, { eq }) => eq(notification.id, id),
|
||||
limit: 1,
|
||||
})
|
||||
)[0];
|
||||
|
||||
if (!notification) return errorResponse("Notification not found", 404);
|
||||
|
||||
return jsonResponse(notification);
|
||||
});
|
||||
79
server/api/api/v1/notifications/clear/index.test.ts
Normal file
79
server/api/api/v1/notifications/clear/index.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
deleteOldTestUsers,
|
||||
getTestUsers,
|
||||
sendTestRequest,
|
||||
} from "~tests/utils";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import { meta } from "./index";
|
||||
|
||||
await deleteOldTestUsers();
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: APINotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await fetch(
|
||||
new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
notifications = await fetch(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/clear
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should clear notifications", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newNotifications = await fetch(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(newNotifications.length).toBe(0);
|
||||
});
|
||||
});
|
||||
32
server/api/api/v1/notifications/clear/index.ts
Normal file
32
server/api/api/v1/notifications/clear/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { apiRoute, applyConfig } from "@api";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { notification } from "~drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/notifications/clear",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:notifications"],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||
const { user } = extraData.auth;
|
||||
if (!user) return errorResponse("Unauthorized", 401);
|
||||
|
||||
await db
|
||||
.update(notification)
|
||||
.set({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(eq(notification.notifiedId, user.id));
|
||||
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
|
@ -6,8 +6,8 @@ import {
|
|||
getTestUsers,
|
||||
sendTestRequest,
|
||||
} from "~tests/utils";
|
||||
import { meta } from "./index";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import { meta } from "./index";
|
||||
|
||||
await deleteOldTestUsers();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const meta = applyConfig({
|
|||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:notifications"],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -115,6 +116,7 @@ export default apiRoute<typeof meta, typeof schema>(
|
|||
: undefined,
|
||||
min_id ? gt(notification.id, min_id) : undefined,
|
||||
eq(notification.notifiedId, user.id),
|
||||
eq(notification.dismissed, false),
|
||||
account_id
|
||||
? eq(notification.accountId, account_id)
|
||||
: undefined,
|
||||
|
|
|
|||
Loading…
Reference in a new issue