Adding a Checkins System to Sharkey

8 min readFeb 14, 2025

Last year I created a social network called Beantown which was derived from the Sharkey open-source project (which itself is a derivative of open-source projects). But all of them connect to each other through ActivityPub. This makes it easy for anyone to create an account on my server and get the benefits while also connecting to other social networks.

What makes Beantown distinct is that it allows you to rate the coffees you drink and where you go, sort of like Untappd (but for coffee). Beyond that, I have built it in such a way that others can take it and adapt the concept to other things that can be checked-in and rated, like wines, musicals, or ice creams.

When a member of Beantown creates a new post, they can select the coffee from a drop-down menu.

But how exactly do I accomplish this? Sharkey does not have support for checking in items. So in this blog post I want to describe the changes I made.

TypeORM and Database

The first thing I need is to update the database. There’s already a SQL database, but I need to add an entirely new table to store my list. To make this abstract, I’m going to call this a checkinable.

My table will have four distinct fields (in addition to ID):

  1. label - The name of the checkinable, ie. “Counter Culture Apollo”
  2. classification - A description of the checkinable kind, ie. “Medium Roast”
  3. source - The creator org of the checkinable, ie. “Counter Culture”
  4. icon - A URL pointing to the checkinable icon. I can periodically add these to my applications assets folder

Sharkey’s database uses a tool called TypeORM to make database migrations easier. All I need to do is add a new migration script which will either create or drop the table.

import { Table } from 'typeorm';

export class Checkinable1737508489687 {
async up(queryRunner) {
await queryRunner.createTable(
new Table({
name: 'checkinable',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
isGenerated: true,
},
{
name: 'label',
type: 'varchar',
length: '4095',
},
{
name: 'classification',
type: 'varchar',
length: '255',
},
{
name: 'source',
type: 'varchar',
length: '4095',
},
{
name: 'icon',
type: 'varchar',
length: '4095',
},
],
}),
)}

async down(queryRunner) {
await queryRunner.dropTable('checkinable')
}
}

Once that is done, I will need to update the backend in various places. This means adding a model file that is the same type as what I defined in my table. I also add CheckinableRepository and related classes. It took me a bit of time to locate all the different code pointers. I had to add a json-schema version of the model. I had to add it to my Postgres model.

import { Entity, Column, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';

@Entity()
export class Checkinable {
@PrimaryColumn(id())
id: string;

/** The label of the coffee, ie. 'Counter Culture Intango' */
@Column()
label: string;

/** The classification of the coffee, ie. 'Dark Roast' */
@Column()
classification: string;

/** The company or source of the coffee, ie. 'Counter Culture' */
@Column()
source: string;

/** URL pointing to the coffee icon, ie. 'http://.../counterculture.png' */
@Column()
icon: string;

constructor(data: Partial<Checkinable>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

There is also a CheckinableEntityService which appears to help do JSON type management between the client and server. Again, after creating this file, I need to make sure it is properly imported and injected in a bunch of different files.

Since the backend is built using NestJS and various frameworks, it was a bit difficult to track down the cause of errors via stack traces. I knew it wasn’t working. I used similarly existing references, other repositories and entity services, in order to see where I was missing my imports.

Writing the API

For this feature, I want to (1) Create new Checkinables, (2) Delete a Checkinable, and (3) List all Checkinables.

Right now I think that’s the bare minimum. I could add list pagination, but that’s for the future. I could add updates instead of deleting and creating anew, but that approach can work as an MVP. So those are the three that I’ve built.

Each API endpoint can be defined as its own file. There’s a whole endpoints directory full of them. Defining the endpoints includes metadata on how to run it. For instance, I can use the requireAdmin property so that only admins can add Checkinables to prevent spam.

I also need to add the request parameters. Then, I can use my CheckinableRepository to perform a simple insertion. You can see the code below:

import { Inject, Injectable } from '@nestjs/common';
import { Checkinable } from '@/models/_.js';
import type { CheckinableRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { IdService } from '@/core/IdService.js';
import { v4 as uuidv4 } from 'uuid';
import { uuid } from 'systeminformation';

export const meta = {
tags: ['checkinable'],

requireCredential: true,
requireAdmin: true,
prohibitMoved: true,

kind: 'write:checkinable',

errors: {
unauthorized: {
message: 'Unauthorized',
code: 'UNAUTHORIZED',
id: 'b8b',
}
},
} as const;

export const paramDef = {
type: 'object',
properties: {
label: { type: 'string' },
classification: { type: 'string' },
source: { type: 'string' },
icon: { type: 'string' },
},
required: ['label', 'classification', 'source', 'icon'],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.checkinableRepository)
private checkinableRepository: CheckinableRepository,
) {
super(meta, paramDef, async (ps, me) => {

const checkinable = await this.checkinableRepository.insert(new Checkinable({
id: uuidv4(),
label: ps.label,
classification: ps.classification,
source: ps.source,
icon: ps.icon,
}))

return checkinable
});
}
}

Delete is very similar, where I get the request and perform a delete operation in my database.

Listing uses the QueryService to create a pagination query on the database. In theory, this service can include a variety of conditions to help filter down the request. Right now I’m not taking advantage of it, but future development would be simple to add. For listing, I want every user to to get read it, but not anyone on the web. I can use requireCredential and not requireAdmin.

import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { CheckinableRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { CheckinableEntityService } from '@/core/entities/CheckinableEntityService.js';
import { DI } from '@/di-symbols.js';

export const meta = {
tags: ['checkinable'],

requireCredential: true,

kind: 'read:checkinable',

res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Checkinable',
},
},
} as const;

export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
prefix: { type: 'string' },
},
required: [],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.checkinableRepository)
private checkinableRepository: CheckinableRepository,
private checkinEntityService: CheckinableEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.checkinableRepository.createQueryBuilder('checkinable'))
// .andWhere('muting.muterId = :meId', { meId: me.id });

const checkinables = await query
.limit(ps.limit)
.getMany();

return await this.checkinEntityService.packMany(checkinables, me);
});
}
}

I want these three operations to be present in a particular place within my REST API hierarchy to conform to the existing structure.

There’s a file with all of the endpoints.

import * as ep___checkinables_list from './endpoints/checkinables/list.js';
import * as ep___checkinables_create from './endpoints/checkinables/create.js';
import * as ep___checkinables_delete from './endpoints/checkinables/delete.js';
// ...
['checkinables/list', ep___checkinables_list],
['checkinables/create', ep___checkinables_create],
['checkinables/delete', ep___checkinables_delete],

It seems like I also need to make sure they’re included in the EndpointsModule:

import * as ep___checkinables_list from './endpoints/checkinables/list.js';
import * as ep___checkinables_create from './endpoints/checkinables/create.js';
import * as ep___checkinables_delete from './endpoints/checkinables/delete.js';
// ...
const $checkinables_list: Provider = { provide: 'ep:checkinables/list', useClass: ep___checkinables_list.default };
const $checkinables_create: Provider = { provide: 'ep:checkinables/create', useClass: ep___checkinables_create.default };
const $checkinables_delete: Provider = { provide: 'ep:checkinables/delete', useClass: ep___checkinables_delete.default };
// ...

Performing CRUD operations

I also need to add a page on the front end that can call these APIs. Since I want this page only to be available for admins, I can add a new admin subpage.

{
path: '/admin',
component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
children: [{
// ...
{
path: '/checkinables',
name: 'checkinables',
component: page(() => import('./pages/admin/checkinable.vue')),
}
// ...
]}
}

I also need to include this additional field in the admin page’s sidebar.

{
icon: 'ph-coffee ph-bold ph-lg-search',
text: 'Checkinables',
to: '/admin/checkinables',
active: currentPage?.route.name === 'checkinables',
}

My Checkinable editor right now is quite simple. There’s a form at the top for adding new entries and a few lists.

let allCheckins = $ref([])

const loadList = () => {
os.api('checkinables/list')
.then(res => {
allCheckins = res
})
}
loadList()

async function insertRecord() {
const raw = await os.api('checkinables/create', {
label: props.label,
classification: props.classification,
source: props.source,
icon: props.icon,
});
await loadList()
}

async function deleteRecord(recordId) {
const con = confirm("Are you sure you want to delete this?")
if (!con) return
await os.api('checkinables/delete', {
id: recordId,
})
await loadList()
}
<h2>Add Checkinable</h2>
<form @submit.prevent="insertRecord">
<table>
<tr>
<td>
<label for="label">Label:</label>
</td>
<td>
<input type="text" id="label" v-model="props.label" required>
</td>
</tr>
<tr>
<td>
<label for="classification">Classification:</label>
</td>
<td>
<input type="text" id="classification" v-model="props.classification" required>
</td>
</tr>
<tr>
<td>
<label for="source">Source:</label>
</td>
<td>
<input type="text" id="source" v-model="props.source" required>
</td>
</tr>
<tr>
<td>
<label for="icon">Icon URL:</label>
</td>
<td>
<input type="url" id="icon" v-model="props.icon" required>
</td>
</tr>
<button type="submit">Submit</button>
</table>
</form>

<h3>Classifications</h3>
<ul>
<li v-for="c in classification">{{ c }}</li>
</ul>

<h2>Existing Checkinables</h2>
<table>
<tr v-for="item in allCheckins">
<td>{{item.id}}</td>
<td>{{item.label}}</td>
<td>{{item.classification}}</td>
<td>{{item.source}}</td>
<td><img :src='item.icon' :alt="item.icon" /></td>
<td>
<button @click="deleteRecord(item.id)">
X
</button>
</td>
</tr>
</table>

Finally I can populate the post form with all the coffees along with a simple caching mechanism.

import * as os from '@/os.js';
export async function loadCoffees() {
if (coffees.length) return coffees
const res = await os.api('checkinables/list')
coffees = res
return coffees
}

Conclusion

Now that I’ve got all this figured out, I do want to get more into coffees. It’s quite easy to just drink anything, but I think it would be good to appreciate the beans more. I recently read Coffeeland and it did make me think a lot more about something I take for granted.

To make it easier for reference, I have put all of these changes into a single commit which will help me as well in case I ever decide to make a change or add a new feature.

The source code for this project can be found on GitHub. And you can start rating your coffees at https://Beantown.space.

--

--

Nick Felker
Nick Felker

Written by Nick Felker

Social Media Expert -- Rowan University 2017 -- IoT & Assistant @ Google

No responses yet