Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bcdc1bd
feat(worker): :boom: Initial Commit, intial entry point, prisma
Nudelsuppe42 May 22, 2026
52d761a
feat(worker): :sparkles: Basic structure
Nudelsuppe42 May 22, 2026
fd8e85b
chore(worker): :test_tube: Remove test cron job [ci skip]
Nudelsuppe42 May 22, 2026
552abc1
feat(worker/tasks): :sparkles: Add buildteam webhook audit logging task
Nudelsuppe42 May 23, 2026
3d21935
feat(worker/cron): :sparkles: Remove legacy cron jobs before adding c…
Nudelsuppe42 May 24, 2026
97caa52
feat(worker/tasks): :sparkles: Use Zod for input validation
Nudelsuppe42 May 24, 2026
04094ba
feat(worker/tasks): :sparkles: Implement Review Activity Check
Nudelsuppe42 May 24, 2026
4d51378
fix(worker): :bug: Change prisma import in review activity util
Nudelsuppe42 May 24, 2026
6936bbb
refactor(worker): :recycle: Simplify execution logic of tasks
Nudelsuppe42 May 24, 2026
b54d2ec
feat(worker/tasks): :sparkles: Add Review Activity Check task and cro…
Nudelsuppe42 May 24, 2026
2911c94
feat(worker/discord): :sparkles: Support advanced DM template for dis…
Nudelsuppe42 May 24, 2026
5444c2c
feat(worker/tasks): :sparkles: Internal Logging task, rename buildtea…
Nudelsuppe42 May 24, 2026
11d8b44
feat(worker/tasks): :sparkles: Add purge claims and verifications tas…
Nudelsuppe42 May 27, 2026
3fbf1a9
feat(worker): Add application reminder as weekly cron job
Nudelsuppe42 May 27, 2026
7449f8c
feat(worker): Add Dockerfile and GitHub Actions workflow for worker d…
Nudelsuppe42 May 27, 2026
0020cf8
fix(worker/tasks): :bug: Switch to unknown type from void
Nudelsuppe42 May 27, 2026
34543ff
fix(worker/tasks): :bug: Use nativeEnum instead of enum for zod
Nudelsuppe42 May 27, 2026
4c75e9b
fix(worker): :bug: Update zod import to named import for consistency
Nudelsuppe42 May 27, 2026
5e90876
fix(worker/tasks): :bug: Use Promise.all instead of single promises
Nudelsuppe42 May 27, 2026
72ca745
fix(worker): :bug: Typo
Nudelsuppe42 May 27, 2026
7b8ccb2
fix(worker/Dockerfile): :bug: Use corepack/yarn 4 for turbo prune
Nudelsuppe42 May 27, 2026
97afb5d
fix(worker): :bug: Use relative imports
Nudelsuppe42 May 27, 2026
d7adf89
style(worker): :art: Prettier
Nudelsuppe42 May 27, 2026
6f06688
fix(worker/tasks): :bug: Final bug fixes
Nudelsuppe42 May 27, 2026
6535b65
fix(worker): Imports again
Nudelsuppe42 May 27, 2026
ddbecac
fix(worker): Imports again
Nudelsuppe42 May 27, 2026
8dbc010
fix(worker): Imports again
Nudelsuppe42 May 27, 2026
a675a5e
fix(worker/tasks): Update validation for Discord DM payload schema
Nudelsuppe42 May 27, 2026
df1bfcb
feat(worker): Remove errored jobs if count > 200
Nudelsuppe42 Jun 1, 2026
363d38f
fix(worker): Set body if body is not json
Nudelsuppe42 Jun 1, 2026
5ac8deb
fix(worker/tasks): Add JSDoc
Nudelsuppe42 Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/worker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Docker Publish Worker

permissions:
contents: write
packages: write

on:
push:
branches: [main]
tags: ['worker-v*.*.*']

jobs:
check:
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check_for_changes.outputs.has_changes }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 3
- uses: actions/setup-node@v3
with:
node-version: '22.14.0'
- name: Install turbo
run: npm install -g turbo@2.4.4 && npm install -g turbo-ignore
- name: Check for changes
id: check_for_changes
run: |
if [[ "${GITHUB_REF}" == refs/tags/worker-v* ]]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi

npx turbo-ignore worker --fallback=HEAD^1 && echo "has_changes=false" >> "$GITHUB_OUTPUT" || echo "has_changes=true" >> "$GITHUB_OUTPUT"
build:
runs-on: ubuntu-latest
needs: check
if: needs.check.outputs.has_changes == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 3
- name: Install turbo
run: npm install -g turbo@^2
- name: Login to Docker
run: |
echo "${{ github.token }}" | docker login https://ghcr.io -u ${GITHUB_ACTOR} --password-stdin
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Resolve release tags
id: release_meta
run: |
if [[ "${GITHUB_REF}" == refs/tags/worker-v* ]]; then
VERSION="${GITHUB_REF_NAME#worker-v}"
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid release tag format: ${GITHUB_REF_NAME}. Expected worker-vX.Y.Z"
exit 1
fi

IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "major=${MAJOR}" >> "$GITHUB_OUTPUT"
echo "minor=${MINOR}" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "is_release=false" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
env:
DOCKER_BUILDKIT: 1
IS_RELEASE: ${{ steps.release_meta.outputs.is_release }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
RELEASE_MAJOR: ${{ steps.release_meta.outputs.major }}
RELEASE_MINOR: ${{ steps.release_meta.outputs.minor }}
run: |
IMAGE="ghcr.io/buildtheearth/website-worker"
TAGS=(
--tag "${IMAGE}:sha-$(git rev-parse --short=12 HEAD)"
)

if [[ "${IS_RELEASE}" == "true" ]]; then
TAGS+=(
--tag "${IMAGE}:${RELEASE_VERSION}"
--tag "${IMAGE}:${RELEASE_MAJOR}.${RELEASE_MINOR}"
--tag "${IMAGE}:${RELEASE_MAJOR}"
--tag "${IMAGE}:latest"
)
fi

docker buildx build . \
--file apps/worker/Dockerfile \
--label org.opencontainers.image.source="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label org.opencontainers.image.revision="${GITHUB_SHA}" \
--label org.opencontainers.image.version="${RELEASE_VERSION:-sha-$(git rev-parse --short=12 HEAD)}" \
"${TAGS[@]}" \
--push
- name: Publish GitHub release
if: steps.release_meta.outputs.is_release == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: worker-v${{ steps.release_meta.outputs.version }}
generate_release_notes: true
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
"frontend/map",
"frontend/assets",
"dash/team",
"frontend/mobile"
"frontend/mobile",
"worker",
"worker/tasks"
],
"js/ts.tsdk.path": "node_modules\\typescript\\lib"
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This repository contains the following apps and other shared packages:
| apps/frontend | BuildTheEarth Website frontend | Next.js, TypeScript | https://buildtheearth.net |
| apps/api | BuildTheEarth API and Website backend | Express.js, TypeScript | https://api.buildtheearth.net |
| apps/dashboard | BuildTheEarth Dashboard | Next.js, TypeScript | https://my.buildtheearth.net |
| apps/worker | Queue Worker for async tasks | BullMQ, TypeScript | -/- |
| packages/typescript-config | Shared tsconfig for all apps | TypeScript, JSON | -/- |
| packages/prettier-config | Shared Prettier config for all apps | Prettier, JSON | -/- |
| packages/db | Shared Prisma client and schema | Prisma.js | -/- |
Expand Down
5 changes: 5 additions & 0 deletions apps/worker/.infisical.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"workspaceId": "1609eb2b-2a36-4273-bd12-470f3f3ad35e",
"defaultEnvironment": "dev",
"gitBranchToEnvironmentMapping": null
}
36 changes: 36 additions & 0 deletions apps/worker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
FROM node:22-alpine AS base

FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Run turbo
RUN corepack enable
RUN corepack prepare yarn@4.9.1 --activate
COPY . .
RUN yarn dlx turbo@2.1.1 prune worker --docker

# Add lockfile and package.json
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies
COPY --from=builder /app/out/json/ .
# Enable corepack to use the correct Yarn version
RUN corepack enable
RUN corepack prepare yarn@4.9.1 --activate
RUN yarn install

# Build the project
COPY --from=builder /app/out/full/ .
RUN yarn turbo run build --filter=worker...

FROM base AS runner
WORKDIR /app

COPY --from=installer /app .

CMD ["node", "apps/worker/dist/main.js"]
Comment thread
kyanvde marked this conversation as resolved.
14 changes: 14 additions & 0 deletions apps/worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- markdownlint-disable -->
<div align="center">

<img width="128" src="https://github.com/BuildTheEarth/assets/blob/main/logos/logo.png?raw=true" />

# Website Worker

_Independent worker for handling events._

![official](https://go.buildtheearth.net/official-shield)
[![chat](https://img.shields.io/discord/706317564904472627.svg?color=768AD4&label=discord&logo=https%3A%2F%2Fdiscordapp.com%2Fassets%2F8c9701b98ad4372b58f13fd9f65f966e.svg)](https://discord.gg/buildtheearth)

</div>
<!-- markdownlint-restore -->
32 changes: 32 additions & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "worker",
"version": "1.0.0",
"private": true,
"packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538",
"scripts": {
"dev": "infisical run --path=\"/worker\" -- tsx watch --tsconfig tsconfig.runtime.json src/main.ts",
"build": "tsc",
"start": "infisical run --path=\"/worker\" -- tsx --tsconfig tsconfig.runtime.json dist/main.js",
"prettier": "prettier ./src --write --ignore-unknown",
"test": "infisical run --path=\"/worker\" -- tsx --tsconfig tsconfig.test.json test/index.test.ts"
},
"devDependencies": {
"@repo/prettier-config": "workspace:^",
"@repo/typescript-config": "workspace:^",
"@types/node": "^22",
"tsx": "^4.22.3",
"typescript": "^5.6.3"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@repo/db": "*",
"bullmq": "^5.77.0",
"ioredis": "^5.10.1",
"zod": "^4.1.13",
"winston": "^3.19.0"
},
"prettier": "@repo/prettier-config",
"lint-staged": {
"*": "prettier --write --ignore-unknown"
}
}
62 changes: 62 additions & 0 deletions apps/worker/src/lib/buildteamWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { logger } from './logger';
import prisma from './prisma';

type WebhookPayload = {
type: string;
data: any;
};
export type WebhookBuildTeam =
| {
url: string;
}
| { id: string }
| { slug: string };

export class BuildTeamWebhook {
async send(
destination: WebhookBuildTeam,
type: string,
data: WebhookPayload['data'],
): Promise<{ ok: boolean; status: number; error?: string }> {
const url = 'url' in destination ? destination.url : await this.resolveUrl(destination);

if (!url) {
return { ok: true, status: 202 };
Comment thread
kyanvde marked this conversation as resolved.
}
Comment thread
Nudelsuppe42 marked this conversation as resolved.

try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'BuildTheEarth Worker',
Accept: 'application/json',
},
body: JSON.stringify({ type, data }),
});

const body = await res.text();

if (!res.ok) {
logger.error('BuildTeam webhook request failed', { status: res.status });
return { ok: false, status: res.status, error: typeof body === 'string' ? body : undefined };
}

logger.debug('BuildTeam webhook sent', { status: res.status });
return { ok: true, status: res.status };
} catch (err: any) {
logger.error('BuildTeam webhook error', { error: err?.message });
return { ok: false, status: 0, error: err?.message };
}
}

private async resolveUrl(destination: WebhookBuildTeam): Promise<string | null> {
const bt = await prisma.buildTeam.findUnique({
where:
'id' in destination ? { id: destination.id } : 'slug' in destination ? { slug: destination.slug } : { id: '' },
});
return bt?.webhook || null;
}
}

export default new BuildTeamWebhook();
31 changes: 31 additions & 0 deletions apps/worker/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Static constant configuration values
*/

import { remove } from 'winston';

export const config = {
// The number of worker threads to spawn for processing background jobs
workerThreadCount: 5,
eventQueueName: 'EventQueue',
retryOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000, // Initial delay of 1 second for retries
},
},
removalOptions: {
removeOnComplete: {
age: 3600, // 1h
count: 200,
},
removeOnFail: {
count: 200,
},
},
webhooks: {
errorReporting: process.env.DISCORD_WEBHOOK_ERRORS || '',
logging: process.env.DISCORD_WEBHOOK_LOGGING || '',
},
};
78 changes: 78 additions & 0 deletions apps/worker/src/lib/discordBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { z } from 'zod';

export type DiscordDmResult = {
success: string[];
failure: string[];
};

export enum DiscordBotEmojisRaw {
WARN = '<:warn:1441532241628102686>',
UNMUTE = '<:unmute:1441532234573156433>',
UNBAN = '<:unban:1441532232627130548>',
MUTE = '<:mute:1441532230643224587>',
KICK = '<:kick:1441532228550131725>',
INVALID = '<:invalid:1441532226427813901>',
INFORMATION = '<:information:1441532225119191175>',
INPROGRESS = '<:inprogress:1441532224473268234>',
FORWARDED = '<:forwarded:1441532223298863305>',
DUPLICATE = '<:duplicate:1441532221470146663>',
DENIED = '<:denied:1441532217779294350>',
BAN = '<:ban:1441532215828676790>',
APPROVED = '<:approved:1441532214562128034>',
}
export enum DiscordBotEmojis {
WARN = 'WARN',
UNMUTE = 'UNMUTE',
UNBAN = 'UNBAN',
MUTE = 'MUTE',
KICK = 'KICK',
INVALID = 'INVALID',
INFORMATION = 'INFORMATION',
INPROGRESS = 'INPROGRESS',
FORWARDED = 'FORWARDED',
DUPLICATE = 'DUPLICATE',
DENIED = 'DENIED',
BAN = 'BAN',
APPROVED = 'APPROVED',
}

export const discordBotMessageMessageSchema = z.object({
title: z.string(),
emoji: z.nativeEnum(DiscordBotEmojis),
body: z.string(),
footer: z.string().optional(),
});

export async function sendDiscordDm(
message: string | z.infer<typeof discordBotMessageMessageSchema>,
users: string[],
): Promise<DiscordDmResult> {
try {
const content =
typeof message === 'string'
? message
: `## ${DiscordBotEmojisRaw[message.emoji]} ${message.title}\n\n${message.body}${
message.footer ? `\n\n-# ${message.footer}` : ''
}`;

const res = await fetch(process.env.DISCORD_BOT_API_URL + '/api/v1/website/message/blank', {
method: 'POST',
headers: {
'Content-type': 'application/json',
authorization: `Bearer ${process.env.DISCORD_BOT_SECRET}`,
},
body: JSON.stringify({ params: { text: content }, ids: users }),
});
const json = await res.json();
return {
success: json.success || [],
failure: json.failed || [],
};
} catch (e) {
console.error(e);
return {
Comment thread
Nudelsuppe42 marked this conversation as resolved.
Comment thread
Nudelsuppe42 marked this conversation as resolved.
success: [],
failure: users,
};
}
}
Loading