Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 0 additions & 41 deletions .github/ISSUE_TEMPLATE/bug_report.yml

This file was deleted.

19 changes: 0 additions & 19 deletions .github/ISSUE_TEMPLATE/config.yml

This file was deleted.

14 changes: 0 additions & 14 deletions .github/PULL_REQUEST_TEMPLATE.md

This file was deleted.

1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ RUN npm run build
# Finally, build the production image with minimal footprint
FROM base

ENV PORT="8080"
ENV NODE_ENV="production"

WORKDIR /myapp
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ Prior to your first deployment, you'll need to do a few things:
- Create two apps on Fly, one for staging and one for production:

```sh
fly create rockabilly-stack-template
fly create rockabilly-stack-template-staging
fly create rb-test-26e5
fly create rb-test-26e5-staging
```

- Initialize Git.
Expand All @@ -109,8 +109,8 @@ Prior to your first deployment, you'll need to do a few things:
- Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:

```sh
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app rockabilly-stack-template
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app rockabilly-stack-template-staging
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app rb-test-26e5
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app rb-test-26e5-staging
```

If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret.
Expand All @@ -119,20 +119,20 @@ Prior to your first deployment, you'll need to do a few things:
- Create a single Postgres server and attach it to both production and staging apps:

```sh
fly postgres create --name rockabilly-stack-template-db
fly postgres attach --postgres-app rockabilly-stack-template-db --app rockabilly-stack-template
fly postgres attach --postgres-app rockabilly-stack-template-db --app rockabilly-stack-template-staging
fly postgres create --name rb-test-26e5-db
fly postgres attach --postgres-app rb-test-26e5-db --app rb-test-26e5
fly postgres attach --postgres-app rb-test-26e5-db --app rb-test-26e5-staging
```

This approach allows you to fit a full deployment with production and staging versions of the app and a postgres
database into the free tier of fly.io. A more conventional setup would be to create separate postgres databases for
both production and staging, in which case you would substitute the following commands for the ones above:

```sh
fly postgres create --name rockabilly-stack-template-db
fly postgres create --name rockabilly-stack-template-staging-db
fly postgres attach --postgres-app rockabilly-stack-template-db --app rockabilly-stack-template
fly postgres attach --postgres-app rockabilly-stack-template-staging-db --app rockabilly-stack-template-staging
fly postgres create --name rb-test-26e5-db
fly postgres create --name rb-test-26e5-staging-db
fly postgres attach --postgres-app rb-test-26e5-db --app rb-test-26e5
fly postgres attach --postgres-app rb-test-26e5-staging-db --app rb-test-26e5-staging
```
Fly will take care of setting the DATABASE_URL secret for you.

Expand Down
24 changes: 24 additions & 0 deletions app/background-process.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ChildProcess } from 'child_process'
import { fork } from 'child_process'

let childProcess: ChildProcess | null = null

export function startBackgroundProcessing() {
if (childProcess) return // already running
setTimeout(() => {
restartBackgroundProcessing()
}, 1000)
}

export function restartBackgroundProcessing() {
console.log('Starting background processor')
childProcess = fork('./build/background-processor.js')
childProcess.on('close', (code: number) => {
console.log(`Background processing function terminated with code ${code}`)
})
}

export function stopBackgroundProcessing() {
console.log('Stopping background processor')
childProcess?.kill()
}
20 changes: 20 additions & 0 deletions app/background-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { processWebhookEvents } from '~/webhooks/webhooks.server'
import { registerWebhooks } from '~/webhooks/register-webhooks.server'

const millisecondsBetweenProcessing = 60000 // once every minute

const processStuff = async () => {
await processWebhookEvents()
// TODO: Add other tasks here...
setTimeout(processStuff, millisecondsBetweenProcessing)
}
async function main(): Promise<void> {
console.log(`Starting background processing...`)
registerWebhooks()
// TODO: Add other necessary initialization code here...
setTimeout(processStuff, millisecondsBetweenProcessing)
}

main()

export {}
3 changes: 3 additions & 0 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { EntryContext } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { renderToString } from 'react-dom/server'
import { registerWebhooks } from '~/webhooks/register-webhooks.server'

registerWebhooks()

export default function handleRequest(
request: Request,
Expand Down
13 changes: 13 additions & 0 deletions app/models/note.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,16 @@ export function deleteNote({ id, userId }: Pick<Note, 'id'> & { userId: User['id
where: { id, userId },
})
}

export function updateNote({ id, title, body }: Pick<Partial<Note>, 'id' | 'title' | 'body'>) {
if (!id) return null
return prisma.note.update({
where: {
id,
},
data: {
...(title ? { title } : {}),
...(body ? { body } : {}),
}
})
}
77 changes: 77 additions & 0 deletions app/models/webhook-events.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { WebhookEvent } from '@prisma/client'
import { prisma } from '~/db.server'

export enum WebhookEventState {
// noinspection JSUnusedGlobalSymbols
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
PROCESSED = 'PROCESSED',
FAILED = 'FAILED',
}

export async function isWebhookEventDuplicated(service: string, externalId: string): Promise<boolean> {
const duplicate = await prisma.webhookEvent.findUnique({
where: {
service_externalId: {
service,
externalId,
},
},
})
return !!duplicate
}

export async function addWebhookEventToQueue(service: string, externalId: string, event: string): Promise<void> {
await prisma.webhookEvent.create({
data: {
service,
externalId,
event,
},
})
}

export async function getUnprocessedWebhookEvents({
maxFailures,
excludeFailures,
}: {
maxFailures: number
excludeFailures: boolean
}): Promise<WebhookEvent[]> {
return prisma.webhookEvent.findMany({
where: excludeFailures
? { AND: [{ state: { not: 'FAILED' } }, { state: { not: 'PROCESSED' } }, { failCount: { lt: maxFailures } }] }
: { AND: [{ state: { not: 'PROCESSED' } }, { failCount: { lt: maxFailures } }] },
orderBy: {
createdAt: 'asc',
},
})
}

export async function setWebhookEventState({
service,
externalId,
state,
failReason,
failCount,
}: {
service: string
externalId: string
state: WebhookEventState
failReason?: string
failCount: number
}): Promise<WebhookEvent> {
return prisma.webhookEvent.update({
where: {
service_externalId: {
service: service,
externalId: externalId,
},
},
data: {
state,
failCount,
...(failReason ? { failReason } : {}),
},
})
}
23 changes: 23 additions & 0 deletions app/routes/webhooks/$service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ActionFunction } from '@remix-run/node';
import { json } from '@remix-run/node'
import { validateEndpoint, validateWebhookEvent } from '~/webhooks/webhooks.server'
import { badRequest, notFound } from 'remix-utils'
import { addWebhookEventToQueue, isWebhookEventDuplicated } from '~/models/webhook-events.server'

export const action: ActionFunction = async ({ request }) => {
if (!validateEndpoint(request)) {
return notFound('NOT FOUND')
}
const { service, signatureMatches, shouldProcessEvent, externalId, payload } = await validateWebhookEvent(await request)
if (!signatureMatches) {
return badRequest({})
}
if (!shouldProcessEvent) {
return json({ success: true })
}
if (await isWebhookEventDuplicated(service, externalId!)) {
return json({ success: true, message: 'Previously processed' })
}
await addWebhookEventToQueue(service, externalId!, payload!)
return json({ success: true }, 200)
}
6 changes: 6 additions & 0 deletions app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,9 @@ export function useUser(): User {
export function validateEmail(email: unknown): email is string {
return typeof email === 'string' && email.length > 3 && email.includes('@')
}

export function getMessageFromError(e: unknown): string {
if (typeof e === 'string') return e
if (e instanceof Error) return e.message
return 'MALFORMED ERROR!'
}
Loading