Add job application quick reference for storing profile links and experience
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
Auth-protected CRUD for personal info (LinkedIn, GitHub, etc.) and experience entries, stored in the database so nothing sensitive is in the public repo. Displayed as a categorized panel on the Job Applications page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,3 +69,9 @@ models:
|
||||
fields:
|
||||
deletedAt:
|
||||
resolver: false
|
||||
JobAppReference:
|
||||
model:
|
||||
- adam-french.co.uk/backend/models.JobAppReference
|
||||
fields:
|
||||
deletedAt:
|
||||
resolver: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
22
backend/graph/job_app_reference.resolvers.go
Normal file
22
backend/graph/job_app_reference.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package graph
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *jobAppReferenceResolver) ID(ctx context.Context, obj *models.JobAppReference) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// JobAppReference returns JobAppReferenceResolver implementation.
|
||||
func (r *Resolver) JobAppReference() JobAppReferenceResolver { return &jobAppReferenceResolver{r} }
|
||||
|
||||
type jobAppReferenceResolver struct{ *Resolver }
|
||||
@@ -30,6 +30,13 @@ type CreateFavoriteInput struct {
|
||||
Link *string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
type CreateJobAppReferenceInput struct {
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
SortOrder *int `json:"sortOrder,omitempty"`
|
||||
}
|
||||
|
||||
type CreateJobApplicationInput struct {
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Company string `json:"company"`
|
||||
@@ -112,6 +119,13 @@ type SteamStatus struct {
|
||||
RecentGames []*SteamGame `json:"recentGames"`
|
||||
}
|
||||
|
||||
type UpdateJobAppReferenceInput struct {
|
||||
Category *string `json:"category,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
SortOrder *int `json:"sortOrder,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateJobApplicationInput struct {
|
||||
JobTitle *string `json:"jobTitle,omitempty"`
|
||||
Company *string `json:"company,omitempty"`
|
||||
|
||||
@@ -387,6 +387,63 @@ func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateJobAppReference is the resolver for the createJobAppReference field.
|
||||
func (r *mutationResolver) CreateJobAppReference(ctx context.Context, input model.CreateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
ref := models.JobAppReference{
|
||||
Category: input.Category,
|
||||
Label: input.Label,
|
||||
Value: input.Value,
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
ref.SortOrder = *input.SortOrder
|
||||
}
|
||||
if err := r.Store.DB.Create(&ref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ref, nil
|
||||
}
|
||||
|
||||
// UpdateJobAppReference is the resolver for the updateJobAppReference field.
|
||||
func (r *mutationResolver) UpdateJobAppReference(ctx context.Context, id int, input model.UpdateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
var ref models.JobAppReference
|
||||
if err := r.Store.DB.First(&ref, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Category != nil {
|
||||
ref.Category = *input.Category
|
||||
}
|
||||
if input.Label != nil {
|
||||
ref.Label = *input.Label
|
||||
}
|
||||
if input.Value != nil {
|
||||
ref.Value = *input.Value
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
ref.SortOrder = *input.SortOrder
|
||||
}
|
||||
if err := r.Store.DB.Save(&ref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ref, nil
|
||||
}
|
||||
|
||||
// DeleteJobAppReference is the resolver for the deleteJobAppReference field.
|
||||
func (r *mutationResolver) DeleteJobAppReference(ctx context.Context, id int) (bool, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return false, fmt.Errorf("admin access required")
|
||||
}
|
||||
if err := r.Store.DB.Delete(&models.JobAppReference{}, id).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Users is the resolver for the users field.
|
||||
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||
var users []models.User
|
||||
@@ -635,6 +692,18 @@ func (r *queryResolver) JobApplication(ctx context.Context, id int) (*models.Job
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// JobAppReferences is the resolver for the jobAppReferences field.
|
||||
func (r *queryResolver) JobAppReferences(ctx context.Context) ([]*models.JobAppReference, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
var refs []*models.JobAppReference
|
||||
if err := r.Store.DB.Order("category ASC, sort_order ASC, created_at ASC").Find(&refs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// Mutation returns MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
|
||||
23
backend/graph/schema/job_app_reference.graphql
Normal file
23
backend/graph/schema/job_app_reference.graphql
Normal file
@@ -0,0 +1,23 @@
|
||||
type JobAppReference {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
updatedAt: Time!
|
||||
category: String!
|
||||
label: String!
|
||||
value: String!
|
||||
sortOrder: Int!
|
||||
}
|
||||
|
||||
input CreateJobAppReferenceInput {
|
||||
category: String!
|
||||
label: String!
|
||||
value: String!
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
input UpdateJobAppReferenceInput {
|
||||
category: String
|
||||
label: String
|
||||
value: String
|
||||
sortOrder: Int
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Query {
|
||||
bookmarks: [Bookmark!]!
|
||||
jobApplications: [JobApplication!]!
|
||||
jobApplication(id: ID!): JobApplication
|
||||
jobAppReferences: [JobAppReference!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -36,4 +37,7 @@ type Mutation {
|
||||
createBookmark(input: CreateBookmarkInput!): Bookmark!
|
||||
deleteBookmark(id: ID!): Bookmark!
|
||||
deleteJobApplication(id: ID!): Boolean!
|
||||
createJobAppReference(input: CreateJobAppReferenceInput!): JobAppReference!
|
||||
updateJobAppReference(id: ID!, input: UpdateJobAppReferenceInput!): JobAppReference!
|
||||
deleteJobAppReference(id: ID!): Boolean!
|
||||
}
|
||||
|
||||
@@ -77,6 +77,17 @@ type Bookmark struct {
|
||||
Link string `gorm:"not null" json:"link"`
|
||||
}
|
||||
|
||||
type JobAppReference struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
Category string `gorm:"not null" json:"category"`
|
||||
Label string `gorm:"not null" json:"label"`
|
||||
Value string `gorm:"not null" json:"value"`
|
||||
SortOrder int `gorm:"default:0" json:"sortOrder"`
|
||||
}
|
||||
|
||||
type JobApplication struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
@@ -39,6 +39,7 @@ func migrateDatabase(db *gorm.DB) error {
|
||||
&models.Rowing{},
|
||||
&models.Message{},
|
||||
&models.JobApplication{},
|
||||
&models.JobAppReference{},
|
||||
&models.Bookmark{},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,13 @@ const form = ref({
|
||||
appliedAt: "",
|
||||
});
|
||||
|
||||
const references = ref([]);
|
||||
const refForm = ref({ category: "profile", label: "", value: "" });
|
||||
const editingRefId = ref(null);
|
||||
const editRefForm = ref({});
|
||||
const REF_CATEGORIES = ["profile", "experience"];
|
||||
const REF_FIELDS = `id category label value sortOrder createdAt`;
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn"];
|
||||
|
||||
const APP_FIELDS = `id jobTitle company location url status notes appliedAt createdAt`;
|
||||
@@ -132,6 +139,87 @@ function exportCsv() {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function fetchReferences() {
|
||||
try {
|
||||
const data = await gql(`query { jobAppReferences { ${REF_FIELDS} } }`);
|
||||
references.value = data.jobAppReferences;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReference() {
|
||||
if (!refForm.value.label || !refForm.value.value) return;
|
||||
try {
|
||||
const input = {
|
||||
category: refForm.value.category,
|
||||
label: refForm.value.label,
|
||||
value: refForm.value.value,
|
||||
};
|
||||
const data = await gql(
|
||||
`mutation CreateJobAppReference($input: CreateJobAppReferenceInput!) {
|
||||
createJobAppReference(input: $input) { ${REF_FIELDS} }
|
||||
}`,
|
||||
{ input },
|
||||
);
|
||||
references.value.push(data.createJobAppReference);
|
||||
refForm.value = { category: refForm.value.category, label: "", value: "" };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function startRefEdit(ref) {
|
||||
editingRefId.value = ref.id;
|
||||
editRefForm.value = { category: ref.category, label: ref.label, value: ref.value };
|
||||
}
|
||||
|
||||
function cancelRefEdit() {
|
||||
editingRefId.value = null;
|
||||
editRefForm.value = {};
|
||||
}
|
||||
|
||||
async function saveRefEdit(id) {
|
||||
try {
|
||||
const input = {
|
||||
category: editRefForm.value.category || undefined,
|
||||
label: editRefForm.value.label || undefined,
|
||||
value: editRefForm.value.value || undefined,
|
||||
};
|
||||
const data = await gql(
|
||||
`mutation UpdateJobAppReference($id: ID!, $input: UpdateJobAppReferenceInput!) {
|
||||
updateJobAppReference(id: $id, input: $input) { ${REF_FIELDS} }
|
||||
}`,
|
||||
{ id, input },
|
||||
);
|
||||
const idx = references.value.findIndex((r) => r.id === id);
|
||||
if (idx !== -1) references.value[idx] = data.updateJobAppReference;
|
||||
editingRefId.value = null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReference(id) {
|
||||
try {
|
||||
await gql(
|
||||
`mutation DeleteJobAppReference($id: ID!) { deleteJobAppReference(id: $id) }`,
|
||||
{ id },
|
||||
);
|
||||
references.value = references.value.filter((r) => r.id !== id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function refsByCategory(category) {
|
||||
return references.value.filter((r) => r.category === category);
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const map = {
|
||||
Applied: "status-applied",
|
||||
@@ -144,7 +232,10 @@ function statusClass(status) {
|
||||
return map[status] ?? "";
|
||||
}
|
||||
|
||||
onMounted(fetchApplications);
|
||||
onMounted(() => {
|
||||
fetchApplications();
|
||||
fetchReferences();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -157,6 +248,40 @@ onMounted(fetchApplications);
|
||||
<button class="ja-btn" @click="exportCsv" :disabled="!applications.length">Export CSV</button>
|
||||
</div>
|
||||
|
||||
<div class="ja-ref-section">
|
||||
<h3 class="ja-ref-heading">Quick Reference</h3>
|
||||
<div v-for="cat in REF_CATEGORIES" :key="cat" class="ja-ref-category">
|
||||
<h4 class="ja-ref-cat-label">{{ cat }}</h4>
|
||||
<div v-for="ref in refsByCategory(cat)" :key="ref.id" class="ja-ref-item">
|
||||
<template v-if="editingRefId !== ref.id">
|
||||
<span class="ja-ref-label">{{ ref.label }}</span>
|
||||
<span class="ja-ref-value" :title="ref.value">{{ ref.value }}</span>
|
||||
<button class="ja-btn ja-btn-sm" @click="copyToClipboard(ref.value)" title="Copy">Copy</button>
|
||||
<button class="ja-btn ja-btn-sm" @click="startRefEdit(ref)">Edit</button>
|
||||
<button class="ja-btn ja-btn-sm ja-btn-danger" @click="deleteReference(ref.id)">Delete</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<select v-model="editRefForm.category" class="ja-input ja-input-sm ja-select">
|
||||
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input v-model="editRefForm.label" class="ja-input ja-input-sm" placeholder="Label" />
|
||||
<input v-model="editRefForm.value" class="ja-input ja-input-sm" placeholder="Value" />
|
||||
<button class="ja-btn ja-btn-sm ja-btn-primary" @click="saveRefEdit(ref.id)">Save</button>
|
||||
<button class="ja-btn ja-btn-sm" @click="cancelRefEdit">Cancel</button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="!refsByCategory(cat).length" class="ja-ref-empty">No {{ cat }} items yet.</p>
|
||||
</div>
|
||||
<form class="ja-ref-form" @submit.prevent="createReference">
|
||||
<select v-model="refForm.category" class="ja-input ja-select">
|
||||
<option v-for="c in REF_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input v-model="refForm.label" class="ja-input" placeholder="Label *" required />
|
||||
<input v-model="refForm.value" class="ja-input" placeholder="Value *" required />
|
||||
<button type="submit" class="ja-btn ja-btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form class="ja-form" @submit.prevent="createApplication">
|
||||
<div class="ja-form-row">
|
||||
<input v-model="form.jobTitle" class="ja-input" placeholder="Job title *" required />
|
||||
@@ -429,4 +554,67 @@ onMounted(fetchApplications);
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ja-ref-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ja-ref-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ja-ref-category {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ja-ref-cat-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-transform: capitalize;
|
||||
margin: 0 0 0.35rem 0;
|
||||
}
|
||||
|
||||
.ja-ref-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.ja-ref-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ja-ref-value {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ja-ref-empty {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.ja-ref-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user