Skip to content

Commit

Permalink
Add docker workflow, add test endpoint, improve notification payload (#2
Browse files Browse the repository at this point in the history
)

* Add docker build/push workflow

* Allow postgres customizations

* Use postgres host env var in Dockerfile

* Add fallbacks for unset env vars

* Don't crash app if APN info not provided

* Update TS

* log -> warn

* Create Thunder database if not exists

* Add ability to generate test notification

* Increase fetch timeout

* Add sort type

* Use slim payload object
  • Loading branch information
micahmo authored May 28, 2024
1 parent 57f01e6 commit a85b511
Show file tree
Hide file tree
Showing 14 changed files with 182 additions and 27 deletions.
24 changes: 24 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Publish Docker Image

on: [push, pull_request]

jobs:
build_and_publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

# Build
- name: Build image
run: |
[[ $GITHUB_REF_NAME = "main" ]] && TAG="latest" || TAG=$GITHUB_REF_NAME
TAG="${TAG//\//$'-'}"
docker build . --file Dockerfile --tag ghcr.io/"${GITHUB_REPOSITORY,,}":$TAG
# Push
- name: Push image
run: |
[[ $GITHUB_REF_NAME = "main" ]] && TAG="latest" || TAG=$GITHUB_REF_NAME
TAG="${TAG//\//$'-'}"
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u $GITHUB_REPOSITORY_OWNER --password-stdin
docker push ghcr.io/"${GITHUB_REPOSITORY,,}":$TAG
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ COPY . .
EXPOSE 5100

# Wait for PostgreSQL to be ready and then start the Node.js server
CMD ["wait-for-it", "postgres:5432", "--", "npm", "start"]
CMD wait-for-it $POSTGRES_HOSTNAME:5432 -- npm start
5 changes: 4 additions & 1 deletion example.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
POSTGRES_USER=user
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
POSTGRES_HOSTNAME=postgres
POSTGRES_PORT=5432
POSTGRES_DATABASE=thunder_database

APNS_KEY_ID=key_id
APNS_TEAM_ID=team_id
Expand Down
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"node-cron": "^3.0.3",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.3"
"sequelize": "^6.37.3",
"undici": "^6.16.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
Expand Down
2 changes: 1 addition & 1 deletion src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ dotenv.config();

// Configure database
const sequelize = new Sequelize(
`postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@postgres:5432/database`
`postgres://${process.env.POSTGRES_USER || "postgres"}:${process.env.POSTGRES_PASSWORD || "password"}@${process.env.POSTGRES_HOSTNAME || "postgres"}:${process.env.POSTGRES_PORT || "5432"}/${process.env.POSTGRES_DATABASE || "thunder_database"}`
);

export default sequelize;
7 changes: 6 additions & 1 deletion src/database/models/account_notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ AccountNotification.init(
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
}
},
testQueued: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
},
{ sequelize }
);
Expand Down
23 changes: 21 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {
import sequelize from "./database/database";

// Helper functions
import { addAccountNotification } from "./notifications/notifications";
import { addAccountNotification, generateTestNotification } from "./notifications/notifications";

// Start cron job
import notificationService from "./notifications/service/notification_service";
import { notificationService, checkNotifications } from "./notifications/service/notification_service";
import AccountNotification from "./database/models/account_notification";
notificationService.start();

Expand Down Expand Up @@ -66,6 +66,25 @@ app.delete("/notifications", (req, res) => {
}
});

app.post("/test", async (req: Request, res: Response) => {
const { jwt } = req.body as NotificationRequest;

if (!jwt)
return res.status(400).send("Missing one or more required parameters");

try {
let result = await generateTestNotification(jwt);

res.status(201).json(result);

// Do a check right away
checkNotifications();
} catch (error) {
console.error("Error creating test notification:", error);
res.status(500).json({ error: "Internal Server Error" });
}
});

// Start the server and connect to the database
app.listen(port, async () => {
console.log(`Server is running on http://localhost:${port}`);
Expand Down
26 changes: 16 additions & 10 deletions src/notifications/apns/apns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import {
PrivateMessageView,
} from "lemmy-js-client";

const options = {
token: {
key: "src/notifications/apns/apns.p8",
keyId: process.env.APNS_KEY_ID as string,
teamId: process.env.APNS_TEAM_ID as string,
},
production: false,
};

const provider = new apn.Provider(options);
let provider : apn.Provider;
if (process.env.APNS_KEY_ID && process.env.APNS_TEAM_ID) {
const options = {
token: {
key: "src/notifications/apns/apns.p8",
keyId: process.env.APNS_KEY_ID as string,
teamId: process.env.APNS_TEAM_ID as string,
},
production: false,
};

provider = new apn.Provider(options);
}
else {
console.warn("APN Key ID or Team ID is empty; not initializing APN service.");
}

/**
* Creates the template for the notification to be sent to APNs.
Expand Down
8 changes: 7 additions & 1 deletion src/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ async function deleteAccountNotification(
return await AccountNotification.destroy({ where: { token } });
}

export { addAccountNotification, getAccountNotification, deleteAccountNotification };
async function generateTestNotification(
jwt: string | undefined,
): Promise<[affectedCount: number]> {
return AccountNotification.update({ testQueued: true }, { where: { jwt } });
}

export { addAccountNotification, getAccountNotification, deleteAccountNotification, generateTestNotification };
31 changes: 27 additions & 4 deletions src/notifications/service/notification_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import cron from "node-cron";
import { Op } from "sequelize";
import { isAfter, subMinutes } from "date-fns";
import { LemmyHttp } from "lemmy-js-client";
import { Sequelize } from "sequelize";

import AccountNotification from "../../database/models/account_notification";
import { provider, createAPNSNotification } from "../apns/apns";
import { createUnifiedPushNotification, sendUnifiedPushNotification } from "../unifiedpush/unifiedpush";
import { createUnifiedPushNotification, sendTestUnifiedPushNotification, sendUnifiedPushNotification } from "../unifiedpush/unifiedpush";

// The interval in minutes to check for new notifications
const INTERVAL = 1;
Expand All @@ -29,6 +30,7 @@ async function checkUnreadReplies(
let { replies } = await client.getReplies({
limit: 50,
unread_only: true,
sort: "New",
});

// Filter out replies newer than the last timestamp
Expand Down Expand Up @@ -75,6 +77,7 @@ async function checkUnreadMentions(
let { mentions } = await client.getPersonMentions({
limit: 50,
unread_only: true,
sort: "New",
});

// Filter out mentions newer than the last timestamp
Expand Down Expand Up @@ -147,14 +150,33 @@ async function checkUnreadMessages(
}
}

async function checkTests(notification: AccountNotification) {
if (notification.get("testQueued") as boolean) {
console.log('Found 1 test queued');

const notificationType = notification.get("type") as string;
const token = notification.get("token") as string;

switch (notificationType) {
case "apn":
// TODO: Implement this for APN
case "unifiedPush":
await sendTestUnifiedPushNotification(token);
break;
default:
break;
}
}
}

/**
* The main function that checks for new notifications. This is triggered from a cron job and is ran at every `INTERVAL` minutes.
*/
const checkNotifications = async () => {
triggerDateTime = new Date();

const notifications = await AccountNotification.findAll({
where: { timestamp: { [Op.lt]: subMinutes(triggerDateTime, INTERVAL) } },
where: Sequelize.or({ timestamp: { [Op.lt]: subMinutes(triggerDateTime, INTERVAL) } }, { testQueued: true }),
});

console.log("Found " + notifications.length + " notifications to check");
Expand Down Expand Up @@ -191,9 +213,10 @@ const checkNotifications = async () => {
await checkUnreadReplies(client, notification);
await checkUnreadMentions(client, notification);
await checkUnreadMessages(client, notification);
await checkTests(notification);

// Update the notification timestamp
await notification.update({ timestamp: triggerDateTime });
await notification.update({ timestamp: triggerDateTime, testQueued: false });
} catch (error) {
console.error(error);
}
Expand Down Expand Up @@ -221,4 +244,4 @@ const notificationService = cron.schedule(
}
);

export default notificationService;
export { notificationService, checkNotifications };
25 changes: 23 additions & 2 deletions src/notifications/unifiedpush/unifiedpush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import {
PersonMentionView,
PrivateMessageView,
} from "lemmy-js-client";
import { setGlobalDispatcher, Agent } from "undici";

import { UnifiedPushObject } from "./../../types/unified_push_object";

import { createSlimCommentReplyView } from "../../types/lemmy";

setGlobalDispatcher(new Agent({connect: { timeout: 30_000 }}));

/**
* Creates the template for the notification to be sent to UnifiedPush.
*
Expand All @@ -21,7 +26,8 @@ function createUnifiedPushNotification(
message: PrivateMessageView | undefined
): UnifiedPushObject {
const note: UnifiedPushObject = {
reply: reply,
reply: createSlimCommentReplyView(reply),
// TODO: Use slim models for mention/message below
mention: mention,
message: message,
};
Expand All @@ -48,4 +54,19 @@ async function sendUnifiedPushNotification(
});
}

export { createUnifiedPushNotification, sendUnifiedPushNotification };
/**
* Sends the notification through UnifiedPush.
*/
async function sendTestUnifiedPushNotification(
token: string
) {
fetch(token, {
method: "POST",
body: 'test',
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
}

export { createUnifiedPushNotification, sendUnifiedPushNotification, sendTestUnifiedPushNotification };
37 changes: 37 additions & 0 deletions src/types/lemmy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
CommentReplyView,
} from "lemmy-js-client";

export type SlimCommentReplyView = {
comment_reply_id: number,
comment_content: string,
comment_removed: boolean,
comment_deleted: boolean,
creator_name: string,
creator_actor_id: string,
post_name: string,
community_name: string,
community_actor_id: string,
recipient_name: string,
recipient_actor_id: string,
}

export function createSlimCommentReplyView(reply: CommentReplyView | undefined): SlimCommentReplyView | undefined {
if (!reply) return undefined;

return {
comment_reply_id: reply.comment_reply.id,
comment_content: reply.comment.content,
comment_removed: reply.comment.removed,
comment_deleted: reply.comment.deleted,
creator_name: reply.creator.name,
creator_actor_id: reply.creator.actor_id,
post_name: reply.post.name,
community_name: reply.community.name,
community_actor_id: reply.community.actor_id,
recipient_name: reply.recipient.name,
recipient_actor_id: reply.recipient.actor_id,
};
}

// TODO: Add slim models for mentions and messages
5 changes: 3 additions & 2 deletions src/types/unified_push_object.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
CommentReplyView,
PersonMentionView,
PrivateMessageView,
} from "lemmy-js-client";

import { SlimCommentReplyView } from "./lemmy";

export type UnifiedPushObject = {
mention: PersonMentionView | undefined,
reply: CommentReplyView | undefined,
reply: SlimCommentReplyView | undefined,
message: PrivateMessageView | undefined
};

0 comments on commit a85b511

Please sign in to comment.