Skip to main content

Profiles

Profiles are the foundation of Aya’s community platform. They represent individuals, organizations, and products with rich metadata, multi-language support, and extensive customization options.

Profile Types

Aya supports three profile kinds:

Individual

Personal profiles for community members

Organization

Companies, communities, and groups

Product

Projects, tools, and software products

Individual Profiles

Each user can have exactly one individual profile. This is created automatically during first login and linked via user.individual_profile_id.
CREATE TABLE "user" (
  "id" CHAR(26) PRIMARY KEY,
  "name" TEXT NOT NULL,
  "email" TEXT,
  "github_handle" TEXT,
  "individual_profile_id" CHAR(26) REFERENCES "profile"
);
Use cases:
  • Personal portfolios
  • Developer profiles
  • Community member pages

Organization Profiles

Organizations have members with different roles and permissions. Use cases:
  • Open source projects
  • Companies
  • Community groups
  • Event organizers
Features:
  • Team management with membership roles
  • Profile resources (GitHub repos, links)
  • Custom pages (About, Team, Projects)
  • Profile points and gamification

Product Profiles

Products represent software projects, tools, or services. Use cases:
  • Open source libraries
  • SaaS products
  • Mobile apps
  • Tools and utilities
Features:
  • Linked to author profiles
  • Resource syncing (GitHub repos, package managers)
  • Version history via stories
  • Product-specific custom fields in properties JSON

Profile Structure

Core Fields

apps/services/etc/data/default/migrations/0001_initial.sql
CREATE TABLE "profile" (
  "id" CHAR(26) PRIMARY KEY,
  "slug" TEXT NOT NULL UNIQUE,
  "kind" TEXT NOT NULL,                    -- 'individual', 'organization', 'product'
  "custom_domain" TEXT,                    -- e.g., 'eser.dev'
  "profile_picture_uri" TEXT,
  "pronouns" TEXT,                         -- e.g., 'he/him', 'she/her', 'they/them'
  "properties" JSONB,                      -- Custom metadata (extensible)
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  "updated_at" TIMESTAMP WITH TIME ZONE,
  "deleted_at" TIMESTAMP WITH TIME ZONE,   -- Soft delete
  "approved_at" TIMESTAMP WITH TIME ZONE   -- Moderation approval
);

Translations (i18n)

All user-facing text is stored in the profile_tx (translation) table:
CREATE TABLE "profile_tx" (
  "profile_id" CHAR(26) NOT NULL,
  "locale_code" CHAR(12) NOT NULL,         -- 'en', 'tr', 'pt-PT', etc.
  "title" TEXT NOT NULL,                   -- Display name
  "description" TEXT NOT NULL,             -- Bio/summary
  "properties" JSONB,                      -- Locale-specific metadata
  PRIMARY KEY ("profile_id", "locale_code")
);
3-Tier Fallback: When a profile doesn’t have a translation for the requested locale, Aya falls back to the profile’s default_locale, then to any available locale.

Properties (Extensible Metadata)

The properties JSONB field allows storing custom data without schema changes:
{
  "website": "https://example.com",
  "location": "San Francisco, CA",
  "company": "Acme Inc",
  "hireable": true,
  "badges": ["verified", "early_adopter"],
  "social": {
    "mastodon": "@[email protected]"
  }
}
Profiles can have multiple external links with OAuth integration:
CREATE TABLE "profile_link" (
  "id" CHAR(26) PRIMARY KEY,
  "profile_id" CHAR(26) REFERENCES "profile",
  "kind" TEXT NOT NULL,                    -- 'github', 'linkedin', 'x', 'youtube', 'custom'
  "order" INTEGER NOT NULL,                -- Display order
  "is_managed" BOOLEAN DEFAULT FALSE,      -- Auto-synced from OAuth
  "is_verified" BOOLEAN DEFAULT FALSE,     -- OAuth verification status
  "is_hidden" BOOLEAN DEFAULT FALSE,       -- Hidden from public view
  "remote_id" TEXT,                        -- External platform ID
  "public_id" TEXT,                        -- Username/handle
  "uri" TEXT,                              -- Full URL
  "title" TEXT NOT NULL,                   -- Display label
  "auth_provider" TEXT,                    -- OAuth provider name
  "auth_access_token" TEXT,               -- Encrypted OAuth token
  "auth_access_token_expires_at" TIMESTAMP,
  "auth_refresh_token" TEXT,
  "properties" JSONB
);
GitHub
  • Auto-sync repositories
  • Import README as profile pages
  • Fetch contribution activity
LinkedIn
  • Verify professional identity
  • Import work history
  • Sync profile updates
SpeakerDeck
  • Import presentations
  • Auto-create story posts for talks
Frontend: Creating a GitHub link
import { backend } from "@/modules/backend/backend.ts";

// 1. Initiate OAuth flow
const { authUrl } = await backend.initiateProfileLinkOAuth(
  locale,
  profileSlug,
  "github"
);
window.location.href = authUrl;

// 2. After OAuth callback, finalize connection
const { link } = await backend.finalizeGitHubConnection(
  locale,
  profileSlug,
  code // from OAuth callback
);

// 3. Link is now created with is_managed=true, is_verified=true
// GitHub repos will auto-sync as profile resources

Profile Pages

Profiles can have custom pages (like “About”, “Projects”, “CV”):
CREATE TABLE "profile_page" (
  "id" CHAR(26) PRIMARY KEY,
  "profile_id" CHAR(26) REFERENCES "profile",
  "slug" TEXT NOT NULL,                    -- URL slug (e.g., 'about', 'cv')
  "order" INTEGER NOT NULL,                -- Display order in navigation
  "cover_picture_uri" TEXT,
  "published_at" TIMESTAMP,                -- NULL = draft
  UNIQUE ("profile_id", "slug")
);

CREATE TABLE "profile_page_tx" (
  "profile_page_id" CHAR(26),
  "locale_code" CHAR(12),
  "title" TEXT NOT NULL,
  "summary" TEXT NOT NULL,
  "content" TEXT NOT NULL,                 -- Markdown/MDX content
  PRIMARY KEY ("profile_page_id", "locale_code")
);

Creating a Profile Page

const page = await backend.createProfilePage(locale, profileSlug, {
  slug: "about",
  order: 0,
  title: "About Me",
  summary: "Learn more about my background and interests",
  content: `
# About Me

I'm a software engineer passionate about open source...

## Skills
- JavaScript/TypeScript
- Go
- React
  `,
});
Pages are accessible at /{locale}/{profile-slug}/{page-slug} (e.g., /en/eser/about).

Profile Memberships

Organizations and products can have members with roles:
CREATE TABLE "profile_membership" (
  "id" CHAR(26) PRIMARY KEY,
  "profile_id" CHAR(26) REFERENCES "profile",        -- Organization/product
  "member_profile_id" CHAR(26) REFERENCES "profile", -- Individual profile
  "kind" TEXT NOT NULL,                              -- 'owner', 'admin', 'member', 'contributor'
  "properties" JSONB,                                -- Role-specific metadata
  "started_at" TIMESTAMP,                            -- When membership began
  "finished_at" TIMESTAMP                            -- When membership ended (optional)
);

Membership Kinds

  • owner: Full control, can delete profile
  • admin: Can manage members, edit profile, publish content
  • member: Can contribute content, participate in discussions
  • contributor: Limited access, can submit content for review

Example: Adding a Member

const membership = await backend.addProfileMembership(
  locale,
  "my-organization",
  {
    memberProfileId: "user-individual-profile-id",
    kind: "admin",
    startedAt: new Date().toISOString(),
  }
);

Profile Resources

Resources are external entities linked to a profile (GitHub repos, npm packages, etc.):
CREATE TABLE "profile_resource" (
  "id" CHAR(26) PRIMARY KEY,
  "profile_id" CHAR(26) REFERENCES "profile",
  "profile_link_id" CHAR(26) REFERENCES "profile_link", -- Source link (optional)
  "kind" TEXT NOT NULL,                                 -- 'github_repo', 'npm_package'
  "remote_id" TEXT,
  "uri" TEXT,
  "title" TEXT NOT NULL,
  "description" TEXT,
  "properties" JSONB
);

Auto-Syncing GitHub Repos

When a GitHub link is connected with is_managed=true, repos are automatically imported:
// Backend automatically syncs repos when GitHub link is created
const repos = await backend.listGitHubRepos(locale, profileSlug);

// User can select which repos to add as resources
await backend.createProfileResource(locale, profileSlug, {
  profileLinkId: githubLinkId,
  kind: "github_repo",
  remoteId: "123456789",
  uri: "https://github.com/eser/aya.is",
  title: "aya.is",
  description: "Open source community platform",
  properties: {
    stars: 142,
    forks: 28,
    language: "Go",
    topics: ["community", "open-source"],
  },
});

Profile Permissions

Determining if a user can edit a profile:
Frontend: Checking permissions
const permissions = await backend.getProfilePermissions(locale, profileSlug);

if (permissions.canEdit) {
  // Show edit button
}

if (permissions.canManageMembers) {
  // Show team management
}

if (permissions.canDelete) {
  // Show delete option
}
Backend: Permission logic (business layer)
package profiles

type Permissions struct {
    CanEdit          bool
    CanManageMembers bool
    CanDelete        bool
    CanPublishStories bool
}

func GetPermissions(
    ctx context.Context,
    repo Repository,
    profileID string,
    userID *string,
) (*Permissions, error) {
    if userID == nil {
        return &Permissions{}, nil // Anonymous user
    }

    // Check if user owns individual profile
    user, _ := repo.GetUserByID(ctx, *userID)
    if user.IndividualProfileID == profileID {
        return &Permissions{
            CanEdit:          true,
            CanDelete:        true,
            CanPublishStories: true,
        }, nil
    }

    // Check membership for organization/product profiles
    membership, _ := repo.GetMembership(ctx, profileID, user.IndividualProfileID)
    if membership != nil {
        return &Permissions{
            CanEdit:           membership.Kind == "owner" || membership.Kind == "admin",
            CanManageMembers:  membership.Kind == "owner" || membership.Kind == "admin",
            CanDelete:         membership.Kind == "owner",
            CanPublishStories: true,
        }, nil
    }

    return &Permissions{}, nil
}

Business Logic Examples

Get Profile by Slug

pkg/api/business/profiles/get.go
package profiles

import (
    "context"
    "aya.is/apps/services/pkg/ajan/results"
)

var ErrProfileNotFound = results.ErrNotFound("Profile not found")

func Get(
    ctx context.Context,
    repo Repository,
    localeCode string,
    slug string,
) (*Profile, error) {
    profile, err := repo.GetBySlug(ctx, localeCode, slug)
    if err != nil {
        return nil, err
    }

    // Business rule: Don't show deleted profiles
    if profile.DeletedAt != nil {
        return nil, ErrProfileNotFound
    }

    // Business rule: Only show approved profiles to public
    // (Note: In actual code, this would check user permissions)
    if profile.ApprovedAt == nil {
        return nil, ErrProfileNotFound
    }

    return profile, nil
}

Create Profile

pkg/api/business/profiles/create.go
package profiles

import (
    "context"
    "time"
    "github.com/segmentio/ksuid"
)

type CreateProfileInput struct {
    Slug        string
    Kind        string
    Title       string
    Description string
    LocaleCode  string
}

func Create(
    ctx context.Context,
    repo Repository,
    input CreateProfileInput,
) (*Profile, error) {
    // Business rule: Validate slug format
    if !isValidSlug(input.Slug) {
        return nil, ErrInvalidSlug
    }

    // Business rule: Check slug availability
    exists, _ := repo.SlugExists(ctx, input.Slug)
    if exists {
        return nil, ErrSlugTaken
    }

    // Business rule: Validate kind
    if !isValidKind(input.Kind) {
        return nil, ErrInvalidKind
    }

    profile := &Profile{
        ID:        ksuid.New().String(),
        Slug:      input.Slug,
        Kind:      input.Kind,
        CreatedAt: time.Now(),
    }

    translation := &ProfileTranslation{
        ProfileID:  profile.ID,
        LocaleCode: input.LocaleCode,
        Title:      input.Title,
        Description: input.Description,
    }

    if err := repo.Create(ctx, profile, translation); err != nil {
        return nil, err
    }

    return profile, nil
}

func isValidSlug(slug string) bool {
    // Business rule: Slugs must be lowercase alphanumeric + hyphens
    matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`, slug)
    return matched
}

func isValidKind(kind string) bool {
    return kind == "individual" || kind == "organization" || kind == "product"
}

Frontend Patterns

Profile Route with SSR

src/routes/$locale/$slug/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { profileQueryOptions, profileStoriesQueryOptions } from "@/modules/backend/queries";
import { QueryError } from "@/components/query-error";

export const Route = createFileRoute("/$locale/$slug/")({
  // Prefetch profile for SSR
  loader: async ({ params, context }) => {
    const { locale, slug } = params;

    // Ensure profile data (needed for head())
    const profile = await context.queryClient.ensureQueryData(
      profileQueryOptions(locale, slug)
    );

    // Prefetch stories (optional, component-only)
    await context.queryClient.prefetchQuery(
      profileStoriesQueryOptions(locale, slug)
    );

    return { locale, slug, profile };
  },

  // Meta tags from loader data
  head: ({ loaderData }) => ({
    title: loaderData.profile.title,
    meta: [
      { name: "description", content: loaderData.profile.description },
      { property: "og:title", content: loaderData.profile.title },
      { property: "og:image", content: loaderData.profile.profilePictureUri },
    ],
  }),

  errorComponent: QueryError,
});

function ProfilePage() {
  const { locale, slug } = Route.useParams();

  // Read from hydrated cache
  const { data: profile } = useSuspenseQuery(profileQueryOptions(locale, slug));
  const { data: stories } = useSuspenseQuery(profileStoriesQueryOptions(locale, slug));

  return (
    <div>
      <h1>{profile.title}</h1>
      <p>{profile.description}</p>

      <h2>Stories</h2>
      {stories.map((story) => (
        <StoryCard key={story.id} story={story} />
      ))}
    </div>
  );
}

export default ProfilePage;

Profile Edit Form

src/components/forms/edit-profile-form.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { backend } from "@/modules/backend/backend.ts";
import { profileQueryOptions } from "@/modules/backend/queries";

type Props = {
  locale: string;
  profile: Profile;
};

export function EditProfileForm(props: Props) {
  const [title, setTitle] = useState(props.profile.title);
  const [description, setDescription] = useState(props.profile.description);
  const queryClient = useQueryClient();

  const updateMutation = useMutation({
    mutationFn: () =>
      backend.updateProfile(props.locale, props.profile.slug, {
        title,
        description,
      }),
    onSuccess: (updatedProfile) => {
      // Optimistically update cache
      queryClient.setQueryData(
        profileQueryOptions(props.locale, props.profile.slug).queryKey,
        updatedProfile
      );
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        updateMutation.mutate();
      }}
    >
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Profile title"
      />
      <textarea
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Description"
      />
      <button type="submit" disabled={updateMutation.isPending}>
        {updateMutation.isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Profile Routing

Profiles use slug-based URLs:
  • /{locale}/{slug} - Profile home
  • /{locale}/{slug}/qa - Q&A section
  • /{locale}/{slug}/settings - Profile settings (authenticated)
  • /{locale}/{slug}/stories - All stories
  • /{locale}/{slug}/stories/{story-slug} - Individual story
  • /{locale}/{slug}/{page-slug} - Custom page
Slug Conflicts: Custom page slugs must not conflict with reserved routes (qa, settings, stories, admin).

Next Steps

Stories

Learn about content publishing and story management

Internationalization

Understand multi-language support and fallback patterns

Frontend Development

Build profile UI with React and TanStack Query

Backend Development

Implement profile business logic and adapters

Build docs developers (and LLMs) love