feat(api): Add dismiss, id and clear API endpoints for notifications

This commit is contained in:
Jesse Wierzbinski 2024-04-15 20:00:40 -10:00
parent 47133ac3fe
commit 0ca8000186
No known key found for this signature in database
18 changed files with 3621 additions and 1811 deletions

View file

@ -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,

View file

@ -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<{

View 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

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -195,6 +195,7 @@ export const notification = pgTable("Notification", {
onDelete: "cascade",
onUpdate: "cascade",
}),
dismissed: boolean("dismissed").default(false).notNull(),
});
export const status = pgTable(

View file

@ -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,

View file

@ -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"],

View file

@ -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({

View 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);
});
});

View 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({});
});

View 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);
});
});

View 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);
});

View 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);
});
});

View 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({});
});

View file

@ -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();

View file

@ -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,