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
27 changes: 27 additions & 0 deletions api/src/discovery-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ export type DiscoveryProfile = z.infer<typeof profileSchema>;
export type StoredDiscoveryProfile = DiscoveryProfile & {
boundary?: z.infer<typeof boundarySchema> | null;
};
export const communityBuildersSchema = z.object({
path: z.string(),
baseUrl: z.string(),
status: z.enum(["ok", "unavailable"]),
builders: z.object({
items: z.array(
z.object({ nearAccount: z.string(), name: z.string().nullable(), url: z.string() }),
),
total: z.number().int().nonnegative(),
url: z.string(),
}),
projects: z.object({
items: z.array(
z.object({
title: z.string(),
description: z.string().nullable(),
logoUrl: z.string().nullable(),
url: z.string(),
}),
),
total: z.number().int().nonnegative(),
url: z.string(),
}),
});
export const activitySchema = z.object({
id: z.uuid(),
ownerNodeId: z.uuid(),
Expand Down Expand Up @@ -280,6 +304,9 @@ export const discoveryContract = {
)
.output(z.array(discoveryNodeSchema)),
getDiscoveryNode: oc.input(z.object({ nodeId: z.uuid() })).output(discoveryNodeSchema.nullable()),
getCommunityBuilders: oc
.input(z.object({ nodeId: z.uuid() }))
.output(communityBuildersSchema.nullable()),
getDiscoveryProfile: oc
.input(z.object({ nodeId: z.uuid() }))
.output(profileSchema.nullable())
Expand Down
13 changes: 13 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type MemberDirectoryService,
MemberDirectoryTag,
} from "./services/member-directory";
import { communityPath, createNearbuilders } from "./services/nearbuilders";
import type { NodeEffect, NodeRecord, NodesService } from "./services/nodes";
import { NodesLive, NodesTag } from "./services/nodes";
import {
Expand Down Expand Up @@ -81,6 +82,7 @@ class ApiServices extends Context.Service<
validators: ValidatorsService;
discovery: DiscoveryService;
storage: StorageService;
nearbuilders: ReturnType<typeof createNearbuilders>;
memberDirectory: MemberDirectoryService;
attendance: AttendanceService;
teamJoinRequests: TeamJoinRequestsService;
Expand Down Expand Up @@ -390,6 +392,10 @@ export default createPlugin.withPlugins<PluginsClient>()({
.string()
.default("")
.describe("Repository URL included in the Nominatim User-Agent"),
nearbuildersUrl: z
.url({ protocol: /^https?$/ })
.default("https://nearbuilders.org")
.describe("NEAR Builders base URL for community builders and projects"),
mainnetStakingPoolWhitelist: z
.string()
.default("lockup-whitelist.near")
Expand Down Expand Up @@ -446,6 +452,7 @@ export default createPlugin.withPlugins<PluginsClient>()({
validators: Context.get(services, ValidatorsTag),
discovery: Context.get(services, DiscoveryTag),
storage: Context.get(services, StorageTag),
nearbuilders: createNearbuilders(config.variables.nearbuildersUrl),
memberDirectory: Context.get(services, MemberDirectoryTag),
attendance: Context.get(services, AttendanceTag),
teamJoinRequests: Context.get(services, TeamJoinRequestsTag),
Expand Down Expand Up @@ -890,6 +897,12 @@ export default createPlugin.withPlugins<PluginsClient>()({
const services = yield* ApiServices;
return yield* services.discovery.get(input.nodeId);
}),
getCommunityBuilders: builder.getCommunityBuilders.effect(function* ({ input }) {
const services = yield* ApiServices;
const path = yield* communityPath(input.nodeId, (id) => services.nodes.getById(id));
if (!path) return null;
return yield* Effect.promise(() => services.nearbuilders.forPath(path));
}),
getDiscoveryProfile: builder.getDiscoveryProfile.effect(function* ({ input, context }) {
const services = yield* ApiServices;
return yield* services.discovery.profile(input.nodeId, context);
Expand Down
191 changes: 191 additions & 0 deletions api/src/services/nearbuilders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { Effect } from "effect";
import { z } from "zod";
import type { communityBuildersSchema } from "../discovery-contract";

export const NEARBUILDERS_PREVIEW_LIMIT = 6;
const REQUEST_TIMEOUT_MS = 5000;
const CACHE_TTL_MS = 2 * 60 * 1000;
const STALE_TTL_MS = 60 * 60 * 1000;
const FAILURE_BACKOFF_MS = 30 * 1000;
const MAX_DEPTH = 10;

const builderSchema = z.object({
nearAccount: z.string().min(1),
name: z.string().nullable().optional(),
cityNode: z.string().nullable().optional(),
withdrawnAt: z.string().nullable().optional(),
});
const projectSchema = z.object({
kind: z.string().min(1),
slug: z.string().min(1),
title: z.string().min(1),
description: z.string().nullable().optional(),
logoUrl: z.string().nullable().optional(),
visibility: z.string().optional(),
cityNode: z.string().nullable().optional(),
});
const taggedSchema = z.object({ cityNode: z.string().nullable().optional() });
const pageSchema = z.object({
data: z.array(z.unknown()),
meta: z.object({ total: z.number().int().nonnegative() }).optional(),
});

export type CommunityBuilders = z.infer<typeof communityBuildersSchema>;

interface NodeLink {
slug: string;
parentId: string | null;
}

export const communityPath = Effect.fn("communityPath")(function* <E>(
nodeId: string,
getNode: (id: string) => Effect.Effect<NodeLink | null, E>,
) {
const slugs: string[] = [];
const seen = new Set<string>();
let current: string | null = nodeId;
while (current) {
if (seen.has(current) || slugs.length >= MAX_DEPTH) return null;
seen.add(current);
const node: NodeLink | null = yield* getNode(current);
if (!node) return null;
slugs.push(node.slug.toLowerCase());
current = node.parentId;
}
return slugs.reverse().join("/");
});

function httpsUrl(value: string | null | undefined) {
if (!value) return null;
try {
const url = new URL(value);
return url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}

function matchedPage<T extends { cityNode?: string | null }>(
raw: unknown,
schema: z.ZodType<T>,
path: string,
matches: (item: T) => boolean,
): { items: T[]; total: number } {
const page = pageSchema.parse(raw);
const items = page.data.flatMap((entry) => {
const parsed = schema.safeParse(entry);
return parsed.success && parsed.data.cityNode === path && matches(parsed.data)
? [parsed.data]
: [];
});
const filteredByServer = page.data.every(
(entry) => taggedSchema.safeParse(entry).data?.cityNode === path,
);
const total = filteredByServer ? Math.max(page.meta?.total ?? 0, items.length) : items.length;
return { items, total };
}

export function createNearbuilders(baseUrlInput: string) {
const baseUrl = new URL(baseUrlInput).origin;
const cache = new Map<string, { loadedAt: number; value: CommunityBuilders }>();
const pending = new Map<string, Promise<CommunityBuilders>>();
const failures = new Map<string, number>();

async function request(path: string, params: Record<string, string>) {
const url = new URL(`/api${path}`, baseUrl);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`nearbuilders.org responded with HTTP ${response.status}`);
return response.json();
}

function baseResult(path: string, status: CommunityBuilders["status"]): CommunityBuilders {
return {
path,
baseUrl,
status,
builders: { items: [], total: 0, url: `${baseUrl}/builders` },
projects: { items: [], total: 0, url: `${baseUrl}/projects` },
};
}

async function load(path: string): Promise<CommunityBuilders> {
const [builderPage, projectPage] = await Promise.all([
request("/v1/builders", { cityNode: path }),
request("/v1/projects", { cityNode: path, visibility: "public", limit: "24" }),
]);
const builders = matchedPage(
builderPage,
builderSchema,
path,
(builder) => !builder.withdrawnAt,
);
const projects = matchedPage(
projectPage,
projectSchema,
path,
(project) => !project.visibility || project.visibility === "public",
);
const result = baseResult(path, "ok");
return {
...result,
builders: {
...result.builders,
total: builders.total,
items: builders.items.slice(0, NEARBUILDERS_PREVIEW_LIMIT).map((builder) => ({
nearAccount: builder.nearAccount,
name: builder.name?.trim() || null,
url: `${baseUrl}/builders/${encodeURIComponent(builder.nearAccount)}`,
})),
},
projects: {
...result.projects,
total: projects.total,
items: projects.items.slice(0, NEARBUILDERS_PREVIEW_LIMIT).map((project) => ({
title: project.title,
description: project.description?.trim() || null,
logoUrl: httpsUrl(project.logoUrl),
url: `${baseUrl}/projects/${encodeURIComponent(project.kind)}/${encodeURIComponent(project.slug)}`,
})),
},
};
}

return {
baseUrl,
forPath: (path: string): Promise<CommunityBuilders> => {
const now = Date.now();
const cached = cache.get(path);
if (cached && now - cached.loadedAt < CACHE_TTL_MS) return Promise.resolve(cached.value);
const fallback = () =>
cached && now - cached.loadedAt < STALE_TTL_MS
? cached.value
: baseResult(path, "unavailable");
const failedAt = failures.get(path);
if (failedAt !== undefined && now - failedAt < FAILURE_BACKOFF_MS) {
return Promise.resolve(fallback());
}
const inFlight = pending.get(path);
if (inFlight) return inFlight;
const next = load(path)
.then((value) => {
failures.delete(path);
for (const [key, entry] of cache) {
if (now - entry.loadedAt >= STALE_TTL_MS) cache.delete(key);
}
cache.set(path, { loadedAt: Date.now(), value });
return value;
})
.catch(() => {
failures.set(path, Date.now());
return fallback();
})
.finally(() => pending.delete(path));
pending.set(path, next);
return next;
},
};
}
Loading
Loading