Modulo makers3d desarrollado con codex V 0.0.1

This commit is contained in:
Ryuk Mike
2026-07-29 23:58:58 +02:00
commit 2bedf7cbb7
171 changed files with 29421 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { createHash, randomBytes } from "node:crypto";
import argon2 from "argon2";
import type { FastifyReply, FastifyRequest } from "fastify";
import { query, one } from "./db.js";
export const sessionMaxAgeSeconds = 60 * 60 * 24 * 400;
export type CurrentUser = {
id: string;
email: string;
role: "user" | "admin";
};
declare module "fastify" {
interface FastifyRequest {
currentUser: CurrentUser | null;
}
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = randomBytes(32).toString("hex");
const tokenHash = hashToken(token);
await query(
`
insert into sessions (user_id, token_hash, expires_at)
values ($1, $2, now() + ($3::int * interval '1 second'))
`,
[userId, tokenHash, sessionMaxAgeSeconds]
);
return token;
}
export async function destroySession(token: string): Promise<void> {
await query(`delete from sessions where token_hash = $1`, [hashToken(token)]);
}
export async function refreshSession(token: string): Promise<void> {
await query(
`
update sessions
set expires_at = now() + ($2::int * interval '1 second')
where token_hash = $1
and expires_at > now()
`,
[hashToken(token), sessionMaxAgeSeconds]
);
}
export async function getUserFromSessionToken(token: string): Promise<CurrentUser | null> {
return one<CurrentUser>(
`
select u.id, u.email, u.role
from sessions s
join users u on u.id = s.user_id
where s.token_hash = $1
and s.expires_at > now()
`,
[hashToken(token)]
);
}
export async function attachCurrentUser(request: FastifyRequest): Promise<void> {
const token = request.cookies.makers3d_session;
if (!token) {
request.currentUser = null;
return;
}
request.currentUser = await getUserFromSessionToken(token);
}
export function requireAuth(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
if (!request.currentUser) {
reply.code(401).send({ error: "Authentication required" });
return null;
}
return request.currentUser;
}
export function requireAdmin(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
if (!request.currentUser || request.currentUser.role !== "admin") {
reply.code(403).send({ error: "Admin access required" });
return null;
}
return request.currentUser;
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
const booleanFromEnv = z.preprocess((value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return value === "true" || value === "1";
}
return false;
}, z.boolean());
const configSchema = z.object({
DATABASE_URL: z.string().min(1),
SESSION_SECRET: z.string().min(8).default("change-me"),
API_PORT: z.coerce.number().default(4000),
APP_URL: z.string().default("http://localhost"),
MINIO_ROOT_USER: z.string().default("minioadmin"),
MINIO_ROOT_PASSWORD: z.string().default("minioadmin"),
MINIO_ENDPOINT: z.string().default("minio"),
MINIO_PORT: z.coerce.number().default(9000),
MINIO_USE_SSL: booleanFromEnv.default(false),
MINIO_PUBLIC_BUCKET: z.string().default("makers3d-public"),
MINIO_PRIVATE_BUCKET: z.string().default("makers3d-private"),
PAYMENT_PROVIDER: z.enum(["demo", "mercadopago"]).default("demo"),
MERCADOPAGO_ACCESS_TOKEN: z.string().optional(),
ADMIN_EMAIL: z.string().email().default("admin@makers3d.local"),
ADMIN_PASSWORD: z.string().min(8).default("Admin123!")
});
export const config = configSchema.parse(process.env);
+18
View File
@@ -0,0 +1,18 @@
import { Pool, type QueryResultRow } from "pg";
import { config } from "./config.js";
export const pool = new Pool({
connectionString: config.DATABASE_URL
});
export async function query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T[]> {
const result = await pool.query<T>(sql, params);
return result.rows;
}
export async function one<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T | null> {
const rows = await query<T>(sql, params);
return rows[0] ?? null;
}
+277
View File
@@ -0,0 +1,277 @@
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
full_name text not null,
role text not null default 'user' check (role in ('user', 'admin')),
created_at timestamptz not null default now()
);
create table if not exists sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create table if not exists maker_profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null unique references users(id) on delete cascade,
slug text not null unique,
business_name text,
description text,
province text,
city text,
latitude double precision,
longitude double precision,
public_latitude double precision,
public_longitude double precision,
delivery_scope text,
availability text not null default 'available' check (availability in ('available', 'limited', 'unavailable')),
public_contact_email text,
public_whatsapp text,
website_url text,
instagram_url text,
tiktok_url text,
twitter_url text,
facebook_url text,
youtube_url text,
linkedin_url text,
allow_verified_reviews boolean not null default true,
show_satisfaction_score boolean not null default true,
show_review_tags boolean not null default true,
allow_maker_review_reply boolean not null default true,
show_past_reviews boolean not null default true,
status text not null default 'draft' check (status in ('draft', 'published', 'suspended')),
main_image_url text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table maker_profiles add column if not exists website_url text;
alter table maker_profiles add column if not exists instagram_url text;
alter table maker_profiles add column if not exists tiktok_url text;
alter table maker_profiles add column if not exists twitter_url text;
alter table maker_profiles add column if not exists facebook_url text;
alter table maker_profiles add column if not exists youtube_url text;
alter table maker_profiles add column if not exists linkedin_url text;
alter table maker_profiles add column if not exists allow_verified_reviews boolean not null default true;
alter table maker_profiles add column if not exists show_satisfaction_score boolean not null default true;
alter table maker_profiles add column if not exists show_review_tags boolean not null default true;
alter table maker_profiles add column if not exists allow_maker_review_reply boolean not null default true;
alter table maker_profiles add column if not exists show_past_reviews boolean not null default true;
create table if not exists services (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
slug text not null unique,
title text not null,
category text not null,
description text not null,
technologies text[] not null default '{}',
materials text[] not null default '{}',
local_pickup boolean not null default true,
nationwide_shipping boolean not null default false,
lead_time_days integer not null default 3,
price_from_cents integer,
draft_data jsonb not null default '{}'::jsonb,
is_published boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table services add column if not exists draft_data jsonb not null default '{}'::jsonb;
create table if not exists works (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
service_id uuid references services(id) on delete set null,
slug text not null unique,
title text not null,
summary text not null,
story text not null,
technology text not null,
material text not null,
image_url text not null,
gallery_urls text[] not null default '{}',
draft_data jsonb not null default '{}'::jsonb,
is_published boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table works add column if not exists draft_data jsonb not null default '{}'::jsonb;
create table if not exists showcases (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
slug text not null unique,
title text not null,
description text not null,
cover_image_url text,
selected_work_ids uuid[] not null default '{}',
featured_work_id uuid references works(id) on delete set null,
draft_data jsonb not null default '{}'::jsonb,
is_published boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table showcases add column if not exists cover_image_url text;
alter table showcases add column if not exists selected_work_ids uuid[] not null default '{}';
alter table showcases add column if not exists featured_work_id uuid references works(id) on delete set null;
alter table showcases add column if not exists draft_data jsonb not null default '{}'::jsonb;
create table if not exists maker_locations (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
label text not null,
location_type text not null,
address text,
postal_code text,
province text not null,
city text not null,
latitude double precision,
longitude double precision,
privacy text not null default 'approximate',
local_pickup boolean not null default true,
personal_delivery boolean not null default true,
postal_shipping boolean not null default true,
nationwide_shipping boolean not null default false,
mobile_service boolean not null default false,
onsite_service boolean not null default false,
notes text,
travel_radius text,
shipping_area text,
hours_mode text not null default 'appointment',
alert_service text,
alert_radius text,
minimum_rating text,
draft_data jsonb not null default '{}'::jsonb,
is_published boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table maker_locations add column if not exists draft_data jsonb not null default '{}'::jsonb;
alter table maker_locations add column if not exists is_published boolean not null default false;
create table if not exists subscriptions (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
provider text not null,
plan_code text not null,
status text not null check (status in ('inactive', 'pending', 'active', 'grace', 'cancelled')),
external_reference text,
activated_at timestamptz,
expires_at timestamptz,
created_at timestamptz not null default now()
);
create table if not exists inquiries (
id uuid primary key default gen_random_uuid(),
maker_id uuid not null references maker_profiles(id) on delete cascade,
customer_id uuid not null references users(id) on delete cascade,
source_type text not null,
source_id uuid,
need_text text not null,
size_text text,
has_model boolean not null default false,
material_preference text,
quantity integer not null default 1,
urgency text not null,
delivery_type text not null,
status text not null default 'open' check (status in ('open', 'rejected', 'completed')),
rejection_reason text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table inquiries drop constraint if exists inquiries_status_check;
alter table inquiries add constraint inquiries_status_check check (status in ('open', 'agreed', 'completed', 'rejected', 'incident'));
alter table inquiries add column if not exists proposal_price_cents integer;
alter table inquiries add column if not exists proposal_quantity integer;
alter table inquiries add column if not exists proposal_lead_time_days integer;
alter table inquiries add column if not exists proposal_note text;
alter table inquiries add column if not exists proposal_created_at timestamptz;
alter table inquiries add column if not exists agreed_at timestamptz;
alter table inquiries add column if not exists maker_completed_at timestamptz;
alter table inquiries add column if not exists customer_completed_at timestamptz;
alter table inquiries add column if not exists completed_at timestamptz;
alter table inquiries add column if not exists incident_reason text;
alter table inquiries add column if not exists incident_detail text;
alter table inquiries add column if not exists incident_created_at timestamptz;
update inquiries set completed_at = updated_at where status = 'completed' and completed_at is null;
create table if not exists messages (
id uuid primary key default gen_random_uuid(),
inquiry_id uuid not null references inquiries(id) on delete cascade,
sender_user_id uuid not null references users(id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
create table if not exists reviews (
id uuid primary key default gen_random_uuid(),
inquiry_id uuid not null unique references inquiries(id) on delete cascade,
maker_id uuid not null references maker_profiles(id) on delete cascade,
customer_id uuid not null references users(id) on delete cascade,
rating_overall integer not null check (rating_overall between 1 and 5),
rating_quality integer not null check (rating_quality between 1 and 5),
rating_communication integer not null check (rating_communication between 1 and 5),
rating_value integer not null check (rating_value between 1 and 5),
comment text not null,
status text not null default 'published' check (status in ('published', 'reported', 'hidden')),
created_at timestamptz not null default now()
);
create table if not exists favorite_makers (
user_id uuid not null references users(id) on delete cascade,
maker_id uuid not null references maker_profiles(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (user_id, maker_id)
);
create table if not exists favorite_works (
user_id uuid not null references users(id) on delete cascade,
work_id uuid not null references works(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (user_id, work_id)
);
create table if not exists moderation_cases (
id uuid primary key default gen_random_uuid(),
target_type text not null,
target_id uuid not null,
reporter_user_id uuid references users(id) on delete set null,
reason text not null,
status text not null default 'open' check (status in ('open', 'resolved')),
resolution text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists jobs (
id uuid primary key default gen_random_uuid(),
type text not null,
payload jsonb not null,
status text not null default 'pending' check (status in ('pending', 'processing', 'done', 'failed')),
attempts integer not null default 0,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_maker_profiles_status on maker_profiles(status);
create index if not exists idx_services_maker_id on services(maker_id);
create index if not exists idx_works_maker_id on works(maker_id);
create index if not exists idx_showcases_maker_id on showcases(maker_id);
create index if not exists idx_maker_locations_maker_id on maker_locations(maker_id);
create index if not exists idx_inquiries_maker_id on inquiries(maker_id);
create index if not exists idx_messages_inquiry_id on messages(inquiry_id);
create index if not exists idx_favorite_makers_user_id on favorite_makers(user_id);
create index if not exists idx_favorite_works_user_id on favorite_works(user_id);
create index if not exists idx_jobs_status on jobs(status);
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildPublishChecklist, isPublishReady } from "@makers3d/shared";
test("publish checklist exige suscripcion activa", () => {
const checklist = buildPublishChecklist({
businessName: "Maker",
description: "Descripcion suficiente",
province: "CABA",
city: "Buenos Aires",
servicesCount: 1,
worksCount: 1,
publicContactEmail: "maker@test.com",
subscriptionStatus: "inactive"
});
assert.equal(isPublishReady(checklist), false);
});
+443
View File
@@ -0,0 +1,443 @@
import { buildPublishChecklist, isPublishReady, maskCoordinates, slugify, type Availability, type SubscriptionStatus } from "@makers3d/shared";
import { query, one } from "./db.js";
type MakerRow = {
id: string;
user_id: string;
slug: string;
business_name: string | null;
description: string | null;
province: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
public_latitude: number | null;
public_longitude: number | null;
delivery_scope: string | null;
availability: Availability;
public_contact_email: string | null;
public_whatsapp: string | null;
website_url: string | null;
instagram_url: string | null;
tiktok_url: string | null;
twitter_url: string | null;
facebook_url: string | null;
youtube_url: string | null;
linkedin_url: string | null;
allow_verified_reviews: boolean;
show_satisfaction_score: boolean;
show_review_tags: boolean;
allow_maker_review_reply: boolean;
show_past_reviews: boolean;
status: string;
main_image_url: string | null;
};
export async function getMakerByUserId(userId: string) {
return one<MakerRow>(`select * from maker_profiles where user_id = $1`, [userId]);
}
export async function getMakerBySlug(slug: string) {
return one<MakerRow>(
`select * from maker_profiles where slug = $1 and status in ('draft', 'published', 'suspended')`,
[slug]
);
}
export async function ensureMakerProfile(userId: string): Promise<MakerRow> {
let profile = await getMakerByUserId(userId);
if (profile) {
return profile;
}
const slug = `maker-${userId.slice(0, 8)}`;
const rows = await query<MakerRow>(
`
insert into maker_profiles (
user_id, slug, availability, delivery_scope, status
) values ($1, $2, 'available', 'local', 'draft')
returning *
`,
[userId, slug]
);
return rows[0];
}
export async function getSubscriptionStatus(makerId: string): Promise<SubscriptionStatus> {
const result = await one<{ status: SubscriptionStatus }>(
`select status from subscriptions where maker_id = $1 order by created_at desc limit 1`,
[makerId]
);
return result?.status ?? "inactive";
}
export async function upsertMakerProfile(userId: string, input: {
businessName: string;
description: string;
province: string;
city: string;
latitude: number;
longitude: number;
deliveryScope: string;
availability: Availability;
publicContactEmail?: string;
publicWhatsapp?: string;
mainImageUrl?: string;
websiteUrl?: string;
instagramUrl?: string;
tiktokUrl?: string;
twitterUrl?: string;
facebookUrl?: string;
youtubeUrl?: string;
linkedinUrl?: string;
}) {
const existing = await ensureMakerProfile(userId);
const slugBase = slugify(input.businessName || existing.slug || "maker");
const masked = maskCoordinates(input.latitude, input.longitude);
const rows = await query<MakerRow>(
`
update maker_profiles
set slug = $2,
business_name = $3,
description = $4,
province = $5,
city = $6,
latitude = $7,
longitude = $8,
public_latitude = $9,
public_longitude = $10,
delivery_scope = $11,
availability = $12,
public_contact_email = $13,
public_whatsapp = $14,
main_image_url = coalesce($15, main_image_url),
website_url = $16,
instagram_url = $17,
tiktok_url = $18,
twitter_url = $19,
facebook_url = $20,
youtube_url = $21,
linkedin_url = $22,
updated_at = now()
where user_id = $1
returning *
`,
[
userId,
`${slugBase}-${existing.id.slice(0, 4)}`,
input.businessName,
input.description,
input.province,
input.city,
input.latitude,
input.longitude,
masked.latitude,
masked.longitude,
input.deliveryScope,
input.availability,
input.publicContactEmail || null,
input.publicWhatsapp || null,
input.mainImageUrl || null,
input.websiteUrl || null,
input.instagramUrl || null,
input.tiktokUrl || null,
input.twitterUrl || null,
input.facebookUrl || null,
input.youtubeUrl || null,
input.linkedinUrl || null
]
);
return rows[0];
}
export async function getMakerChecklist(makerId: string) {
const maker = await one<MakerRow>(`select * from maker_profiles where id = $1`, [makerId]);
if (!maker) {
return null;
}
const services = await one<{ count: string }>(`select count(*) from services where maker_id = $1 and is_published = true`, [makerId]);
const works = await one<{ count: string }>(`select count(*) from works where maker_id = $1 and is_published = true`, [makerId]);
const subscriptionStatus = await getSubscriptionStatus(makerId);
const checklist = buildPublishChecklist({
businessName: maker.business_name,
description: maker.description,
province: maker.province,
city: maker.city,
servicesCount: Number(services?.count ?? 0),
worksCount: Number(works?.count ?? 0),
publicContactEmail: maker.public_contact_email,
publicWhatsapp: maker.public_whatsapp,
subscriptionStatus
});
return {
checklist,
ready: isPublishReady(checklist),
subscriptionStatus
};
}
export async function publishMakerIfReady(makerId: string) {
const checklistInfo = await getMakerChecklist(makerId);
if (!checklistInfo || !checklistInfo.ready) {
return { published: false, reason: "Checklist incomplete" };
}
await query(`update maker_profiles set status = 'published', updated_at = now() where id = $1`, [makerId]);
return { published: true };
}
export async function listPublicMakers(filters: {
q?: string;
province?: string;
city?: string;
serviceCategory?: string;
material?: string;
deliveryScope?: string;
}) {
const params: unknown[] = [];
const conditions = [`m.status = 'published'`];
let queryExactIndex = 0;
let queryLikeIndex = 0;
if (filters.q) {
params.push(filters.q.toLowerCase());
queryExactIndex = params.length;
params.push(`%${filters.q.toLowerCase()}%`);
queryLikeIndex = params.length;
conditions.push(`(
lower(m.business_name) like $${queryLikeIndex}
or lower(m.description) like $${queryLikeIndex}
or exists (
select 1 from services s
where s.maker_id = m.id
and s.is_published = true
and (
lower(s.title) like $${queryLikeIndex}
or lower(s.description) like $${queryLikeIndex}
or lower(s.category) like $${queryLikeIndex}
or exists (
select 1
from unnest(s.technologies) technology
where lower(technology) like $${queryLikeIndex}
)
or exists (
select 1
from unnest(s.materials) material
where lower(material) like $${queryLikeIndex}
)
)
)
or exists (
select 1 from works w
where w.maker_id = m.id
and w.is_published = true
and (
lower(w.title) like $${queryLikeIndex}
or lower(w.summary) like $${queryLikeIndex}
or lower(w.story) like $${queryLikeIndex}
or lower(w.technology) like $${queryLikeIndex}
or lower(w.material) like $${queryLikeIndex}
)
)
)`);
}
if (filters.province) {
params.push(filters.province);
conditions.push(`m.province = $${params.length}`);
}
if (filters.city) {
params.push(filters.city);
conditions.push(`m.city = $${params.length}`);
}
if (filters.deliveryScope) {
params.push(filters.deliveryScope);
conditions.push(`m.delivery_scope = $${params.length}`);
}
if (filters.serviceCategory) {
params.push(filters.serviceCategory);
conditions.push(`exists (
select 1 from services s
where s.maker_id = m.id
and s.is_published = true
and s.category = $${params.length}
)`);
}
if (filters.material) {
params.push(filters.material);
conditions.push(`exists (
select 1 from services s
where s.maker_id = m.id
and s.is_published = true
and $${params.length} = any(s.materials)
)`);
}
const matchScoreSelect = filters.q ? `
(
case when lower(m.business_name) = $${queryExactIndex} then 160 else 0 end +
case when lower(m.business_name) like $${queryLikeIndex} then 90 else 0 end +
case when lower(m.description) like $${queryLikeIndex} then 36 else 0 end +
case when exists (
select 1
from services s
where s.maker_id = m.id
and s.is_published = true
and lower(s.title) = $${queryExactIndex}
) then 140 else 0 end +
case when exists (
select 1
from services s
where s.maker_id = m.id
and s.is_published = true
and lower(s.title) like $${queryLikeIndex}
) then 96 else 0 end +
case when exists (
select 1
from services s
where s.maker_id = m.id
and s.is_published = true
and (
lower(s.description) like $${queryLikeIndex}
or lower(s.category) like $${queryLikeIndex}
or exists (
select 1
from unnest(s.technologies) technology
where lower(technology) like $${queryLikeIndex}
)
or exists (
select 1
from unnest(s.materials) material
where lower(material) like $${queryLikeIndex}
)
)
) then 54 else 0 end +
case when exists (
select 1
from works w
where w.maker_id = m.id
and w.is_published = true
and lower(w.title) = $${queryExactIndex}
) then 150 else 0 end +
case when exists (
select 1
from works w
where w.maker_id = m.id
and w.is_published = true
and lower(w.title) like $${queryLikeIndex}
) then 102 else 0 end +
case when exists (
select 1
from works w
where w.maker_id = m.id
and w.is_published = true
and (
lower(w.summary) like $${queryLikeIndex}
or lower(w.story) like $${queryLikeIndex}
or lower(w.technology) like $${queryLikeIndex}
or lower(w.material) like $${queryLikeIndex}
)
) then 66 else 0 end
) as match_score,
` : `
0 as match_score,
`;
const orderBy = filters.q
? `match_score desc, review_count desc, m.business_name asc`
: `review_count desc, m.business_name asc`;
return query(
`
select
m.id,
m.slug,
m.business_name,
m.description,
m.province,
m.city,
m.public_latitude,
m.public_longitude,
m.delivery_scope,
m.availability,
m.main_image_url,
coalesce(
lv.public_locations,
case
when m.public_latitude is not null and m.public_longitude is not null then jsonb_build_array(jsonb_build_object(
'id', m.id,
'label', m.business_name,
'city', m.city,
'province', m.province,
'latitude', m.public_latitude,
'longitude', m.public_longitude
))
else '[]'::jsonb
end
) as public_locations,
coalesce(rv.avg_rating, 0) as avg_rating,
coalesce(rv.review_count, 0) as review_count,
coalesce(sv.service_count, 0) as service_count,
coalesce(wv.work_count, 0) as work_count,
${matchScoreSelect}
coalesce(cv.categories, '{}'::text[]) as categories
from maker_profiles m
left join (
select maker_id, round(avg(rating_overall)::numeric, 1) as avg_rating, count(*) as review_count
from reviews
where status = 'published'
group by maker_id
) rv on rv.maker_id = m.id
left join (
select maker_id, count(*) as service_count
from services
where is_published = true
group by maker_id
) sv on sv.maker_id = m.id
left join (
select maker_id, count(*) as work_count
from works
where is_published = true
group by maker_id
) wv on wv.maker_id = m.id
left join (
select maker_id, array_agg(distinct category order by category) as categories
from services
where is_published = true
group by maker_id
) cv on cv.maker_id = m.id
left join (
select
maker_id,
jsonb_agg(
jsonb_build_object(
'id', id,
'label', label,
'city', city,
'province', province,
'latitude', case when privacy = 'exact' then latitude else round(latitude::numeric, 2)::double precision end,
'longitude', case when privacy = 'exact' then longitude else round(longitude::numeric, 2)::double precision end
)
order by created_at desc
) as public_locations
from maker_locations
where is_published = true
and latitude is not null
and longitude is not null
group by maker_id
) lv on lv.maker_id = m.id
where ${conditions.join(" and ")}
order by ${orderBy}
`,
params
);
}
+31
View File
@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { pool } from "../db.js";
const currentDir = dirname(fileURLToPath(import.meta.url));
const schemaCandidates = [
join(currentDir, "../db/schema.sql"),
join(process.cwd(), "apps/api/src/db/schema.sql"),
join(process.cwd(), "dist/apps/api/src/db/schema.sql")
];
let sql: string | undefined;
for (const schemaPath of schemaCandidates) {
try {
sql = await readFile(schemaPath, "utf8");
break;
} catch {
// Try the next known location. The runtime container keeps the schema outside dist.
}
}
if (!sql) {
throw new Error(`Schema file not found. Checked: ${schemaCandidates.join(", ")}`);
}
await pool.query(sql);
console.log("Database schema applied");
await pool.end();
+403
View File
@@ -0,0 +1,403 @@
import { slugify } from "@makers3d/shared";
import { hashPassword } from "../auth.js";
import { pool, query, one } from "../db.js";
import { getMakerByUserId, publishMakerIfReady, upsertMakerProfile } from "../queries.js";
type DemoMaker = {
businessName: string;
category: "Impresion FDM" | "Impresion Resina" | "Diseno 3D" | "Prototipado";
focus: string;
imageUrl: string;
material: string;
technology: string;
priceFromCents: number;
deliveryScope: "local" | "nationwide";
availability: "available" | "limited";
reviewScore: number;
reviewCount: number;
};
async function ensureUser(email: string, password: string, fullName: string, role: "user" | "admin" = "user") {
const existing = await one<{ id: string }>(`select id from users where email = $1`, [email]);
if (existing) {
return existing.id;
}
const passwordHash = await hashPassword(password);
const rows = await query<{ id: string }>(
`
insert into users (email, password_hash, full_name, role)
values ($1, $2, $3, $4)
returning id
`,
[email, passwordHash, fullName, role]
);
return rows[0].id;
}
async function ensureActiveSubscription(makerId: string) {
await query(
`
insert into subscriptions (maker_id, provider, plan_code, status, activated_at, expires_at)
select $1, 'demo', 'maker-base', 'active', now(), now() + interval '30 days'
where not exists (
select 1
from subscriptions
where maker_id = $1
and provider = 'demo'
and plan_code = 'maker-base'
)
`,
[makerId]
);
}
async function upsertService(makerId: string, slug: string, maker: DemoMaker) {
const rows = await query<{ id: string }>(
`
insert into services (
maker_id, slug, title, category, description, technologies, materials, local_pickup, nationwide_shipping, lead_time_days, price_from_cents, is_published
)
values ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $10, true)
on conflict (slug) do update
set title = excluded.title,
category = excluded.category,
description = excluded.description,
technologies = excluded.technologies,
materials = excluded.materials,
nationwide_shipping = excluded.nationwide_shipping,
lead_time_days = excluded.lead_time_days,
price_from_cents = excluded.price_from_cents,
is_published = true,
updated_at = now()
returning id
`,
[
makerId,
slug,
`${maker.category} para ${maker.focus}`,
maker.category,
`${maker.businessName} resuelve ${maker.focus} con ${maker.technology} y materiales como ${maker.material}.`,
[maker.technology, maker.category === "Diseno 3D" ? "CAD" : "Diseno 3D"],
[maker.material, "PLA", "PETG"],
maker.deliveryScope === "nationwide",
maker.category === "Impresion Resina" ? 4 : 3,
maker.priceFromCents
]
);
return rows[0].id;
}
async function upsertWork(makerId: string, serviceId: string, slug: string, maker: DemoMaker, index: number) {
const workVariantByImage: Record<string, { title: string; summaryTopic: string; storyTopic: string }> = {
"/demo/work-coffee-hinge.svg": {
title: "Restauracion de engranaje",
summaryTopic: "engranaje funcional para cafetera y repuesto mecanico",
storyTopic: "una pieza de transmision y recuperacion de movimiento"
},
"/demo/work-custom.svg": {
title: "Soporte funcional",
summaryTopic: "soporte tecnico para montaje, fijacion o uso real",
storyTopic: "un soporte utilitario para montaje y ajuste"
},
"/demo/work-dashboard-bracket.svg": {
title: "Tablero y carcasa tecnica",
summaryTopic: "tablero de control, panel tecnico y carcasa estructural",
storyTopic: "un componente de tablero, panel o control tecnico"
},
"/demo/maker-hero-1.svg": {
title: "Prototipo validado",
summaryTopic: "prototipo funcional, maqueta y validacion de producto",
storyTopic: "una prueba de concepto y validacion de producto"
}
};
const variant = workVariantByImage[maker.imageUrl] || workVariantByImage["/demo/maker-hero-1.svg"];
const workTitle = `${variant.title} ${maker.businessName}`;
await query(
`
insert into works (
maker_id, service_id, slug, title, summary, story, technology, material, image_url, gallery_urls, is_published
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
on conflict (slug) do update
set title = excluded.title,
summary = excluded.summary,
story = excluded.story,
technology = excluded.technology,
material = excluded.material,
image_url = excluded.image_url,
gallery_urls = excluded.gallery_urls,
is_published = true,
updated_at = now()
`,
[
makerId,
serviceId,
slug,
workTitle,
`${variant.summaryTopic} con enfoque en ${maker.material}, ${maker.technology} y entrega clara.`,
`${maker.businessName} publico este caso como demostracion de ${maker.focus}. Se documento ${variant.storyTopic}, el problema, la solucion y el material utilizado para facilitar comparacion y contacto.`,
maker.technology,
maker.material,
maker.imageUrl,
[maker.imageUrl]
]
);
}
async function ensureInquiryAndReview(makerId: string, makerUserId: string, maker: DemoMaker, customerId: string, reviewIndex: number) {
const needText = `Consulta demo ${reviewIndex + 1} para ${maker.businessName}`;
let inquiry = await one<{ id: string }>(
`select id from inquiries where maker_id = $1 and customer_id = $2 and need_text = $3`,
[makerId, customerId, needText]
);
if (!inquiry) {
const created = await query<{ id: string }>(
`
insert into inquiries (
maker_id, customer_id, source_type, need_text, size_text, has_model, material_preference, quantity, urgency, delivery_type, status
)
values ($1, $2, 'work', $3, '12 cm', true, $4, 1, 'medium', 'shipping', 'completed')
returning id
`,
[makerId, customerId, needText, maker.material]
);
inquiry = created[0];
await query(
`
insert into messages (inquiry_id, sender_user_id, body)
values
($1, $2, 'Hola, quiero algo similar a lo publicado.'),
($1, $3, 'Perfecto, te puedo ayudar con ese trabajo.'),
($1, $2, 'Buenisimo, avancemos con medidas y entrega.')
`,
[inquiry.id, customerId, makerUserId]
);
}
const roundedRating = Math.max(4, Math.round(maker.reviewScore));
await query(
`
insert into reviews (
inquiry_id, maker_id, customer_id, rating_overall, rating_quality, rating_communication, rating_value, comment, status
)
values ($1, $2, $3, $4, $4, $4, $5, $6, 'published')
on conflict (inquiry_id) do update
set rating_overall = excluded.rating_overall,
rating_quality = excluded.rating_quality,
rating_communication = excluded.rating_communication,
rating_value = excluded.rating_value,
comment = excluded.comment,
status = 'published'
`,
[
inquiry.id,
makerId,
customerId,
roundedRating,
Math.max(4, roundedRating - 1),
`${maker.businessName} respondio bien y entrego una solucion convincente para ${maker.focus}.`
]
);
}
const adminId = await ensureUser("admin@makers3d.local", "Admin123!", "Admin", "admin");
const clientIds = await Promise.all(
Array.from({ length: 8 }, (_, index) =>
ensureUser(`cliente${index + 1}@makers3d.local`, "Cliente123!", `Cliente Demo ${index + 1}`)
)
);
const makers: DemoMaker[] = [
{
businessName: "MakerLab 3D",
category: "Impresion FDM",
focus: "repuestos funcionales, ingenieria y piezas de uso real",
imageUrl: "/demo/work-coffee-hinge.svg",
material: "PETG",
technology: "FDM",
priceFromCents: 150000,
deliveryScope: "nationwide",
availability: "available",
reviewScore: 4.9,
reviewCount: 4
},
{
businessName: "PrintCraft",
category: "Impresion FDM",
focus: "series cortas, soportes tecnicos y repuestos industriales",
imageUrl: "/demo/work-custom.svg",
material: "ABS",
technology: "FDM",
priceFromCents: 165000,
deliveryScope: "nationwide",
availability: "available",
reviewScore: 4.8,
reviewCount: 3
},
{
businessName: "3D Ideas",
category: "Diseno 3D",
focus: "prototipos, modelado y piezas personalizadas",
imageUrl: "/demo/maker-hero-custom.svg",
material: "PLA",
technology: "CAD",
priceFromCents: 180000,
deliveryScope: "local",
availability: "available",
reviewScore: 4.7,
reviewCount: 2
},
{
businessName: "ImpresionAR",
category: "Impresion Resina",
focus: "figuras detalladas, miniaturas y piezas en resina",
imageUrl: "/demo/work-dashboard-bracket.svg",
material: "Resina estandar",
technology: "Resina",
priceFromCents: 175000,
deliveryScope: "nationwide",
availability: "available",
reviewScore: 4.8,
reviewCount: 2
},
{
businessName: "ProtoWorks",
category: "Prototipado",
focus: "validacion de producto, prototipos funcionales y ajustes rapidos",
imageUrl: "/demo/work-custom.svg",
material: "PLA",
technology: "FDM",
priceFromCents: 210000,
deliveryScope: "nationwide",
availability: "limited",
reviewScore: 4.6,
reviewCount: 1
},
{
businessName: "Resin Forge",
category: "Impresion Resina",
focus: "piezas finas, moldes chicos y detalle visual en resina",
imageUrl: "/demo/work-dashboard-bracket.svg",
material: "Resina alta definicion",
technology: "Resina",
priceFromCents: 195000,
deliveryScope: "local",
availability: "available",
reviewScore: 4.9,
reviewCount: 1
},
...[
"RepuestoLab",
"CadSur Studio",
"Volumen 3D",
"Nodo Maker",
"Delta Print",
"Origen 3D",
"Atlas Makers",
"Prisma Resina",
"FabCordoba",
"Punto Layer",
"Taller Capa",
"Linea 3D",
"ProtoRio",
"Hexa Print",
"Monoblock 3D",
"Cubo Norte",
"Rayo Maker",
"Ruta FDM",
"Andes Prototype",
"Sur Layers",
"Pixel Resina",
"Brio 3D",
"Arco Maker",
"Nexa CAD",
"Torno 3D",
"Faro Print",
"Cima Resina",
"Plano 3D",
"Malla Lab",
"Vector Maker"
].map<DemoMaker>((businessName, index) => {
const categoryCycle = [
"Impresion FDM",
"Diseno 3D",
"Impresion Resina",
"Prototipado"
] as const;
const imageCycle = [
"/demo/work-coffee-hinge.svg",
"/demo/work-custom.svg",
"/demo/work-dashboard-bracket.svg",
"/demo/maker-hero-1.svg"
];
const focusCycle = [
"repuestos, soportes tecnicos y piezas funcionales",
"modelado, diseno 3D y preparacion de archivos",
"resina, detalle fino y miniaturas",
"prototipos, validacion y ajustes de producto"
];
const materialCycle = ["PETG", "PLA", "Resina estandar", "ABS"] as const;
const technologyCycle = ["FDM", "CAD", "Resina", "FDM"] as const;
return {
businessName,
category: categoryCycle[index % categoryCycle.length],
focus: focusCycle[index % focusCycle.length],
imageUrl: imageCycle[index % imageCycle.length],
material: materialCycle[index % materialCycle.length],
technology: technologyCycle[index % technologyCycle.length],
priceFromCents: 145000 + index * 5000,
deliveryScope: index % 3 === 0 ? "nationwide" : "local",
availability: index % 5 === 0 ? "limited" : "available",
reviewScore: 4.4 + (index % 5) * 0.1,
reviewCount: index % 3
};
})
];
for (const [index, makerSpec] of makers.entries()) {
const makerEmail = `maker${index + 1}@makers3d.local`;
const makerUserId = await ensureUser(makerEmail, "Maker123!", makerSpec.businessName);
const latitude = -31.4201 + (index % 6) * 0.009;
const longitude = -64.1888 + (index % 5) * 0.011;
await upsertMakerProfile(makerUserId, {
businessName: makerSpec.businessName,
description: `${makerSpec.businessName} ofrece ${makerSpec.focus} en Cordoba, con foco en ${makerSpec.category.toLowerCase()}.`,
province: "Cordoba",
city: "Cordoba",
latitude,
longitude,
deliveryScope: makerSpec.deliveryScope,
availability: makerSpec.availability,
publicContactEmail: makerEmail,
publicWhatsapp: `+5493510000${String(index + 1).padStart(3, "0")}`,
mainImageUrl: makerSpec.imageUrl
});
const makerProfile = await getMakerByUserId(makerUserId);
if (!makerProfile) {
throw new Error(`Maker profile missing for ${makerSpec.businessName}`);
}
await ensureActiveSubscription(makerProfile.id);
const baseSlug = slugify(makerSpec.businessName);
const serviceId = await upsertService(makerProfile.id, `${baseSlug}-servicio-demo`, makerSpec);
await upsertWork(makerProfile.id, serviceId, `${baseSlug}-trabajo-demo`, makerSpec, index);
await publishMakerIfReady(makerProfile.id);
for (let reviewIndex = 0; reviewIndex < makerSpec.reviewCount; reviewIndex += 1) {
const customerId = clientIds[(index + reviewIndex) % clientIds.length];
await ensureInquiryAndReview(makerProfile.id, makerUserId, makerSpec, customerId, reviewIndex);
}
}
console.log(`Seed completed. Admin=${adminId} Makers=${makers.length}`);
await pool.end();
+34
View File
@@ -0,0 +1,34 @@
import { Client } from "minio";
import { randomUUID } from "node:crypto";
import { config } from "./config.js";
export const storage = new Client({
endPoint: config.MINIO_ENDPOINT,
port: config.MINIO_PORT,
useSSL: config.MINIO_USE_SSL,
accessKey: config.MINIO_ROOT_USER,
secretKey: config.MINIO_ROOT_PASSWORD
});
export async function ensureBuckets(): Promise<void> {
for (const bucket of [config.MINIO_PUBLIC_BUCKET, config.MINIO_PRIVATE_BUCKET]) {
const exists = await storage.bucketExists(bucket).catch(() => false);
if (!exists) {
await storage.makeBucket(bucket, "us-east-1");
}
}
}
export async function uploadPublicObject(fileName: string, contentType: string, buffer: Buffer): Promise<string> {
const objectName = `${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9.\-_]/g, "-")}`;
await storage.putObject(config.MINIO_PUBLIC_BUCKET, objectName, buffer, buffer.length, {
"Content-Type": contentType
});
return objectName;
}
export async function getPublicObject(objectName: string) {
return storage.getObject(config.MINIO_PUBLIC_BUCKET, objectName);
}