a.focus(),100),o}createMarkdownForm(e){const t=this.getMarkdownConfig(e),n=e.extractMarkdown(),r=document.createElement("div");return r.className="insertr-edit-form",r.innerHTML=`\n \n \n \n Save \n Cancel \n
\n `,r}getMarkdownConfig(e){const t=e.elements.length;if(1!==t)return{label:`Group Content (${t} elements)`,rows:Math.max(8,2*t),placeholder:"Edit all content together using markdown..."};switch(e.elements[0].tagName.toLowerCase()){case"h3":case"h4":case"h5":case"h6":return{label:"Title (Markdown)",rows:2,placeholder:"Enter title using markdown..."};case"p":return{label:"Content (Markdown)",rows:4,placeholder:"Enter content using markdown..."};case"span":return{label:"Text (Markdown)",rows:2,placeholder:"Enter text using markdown..."};default:return{label:"Content (Markdown)",rows:3,placeholder:"Enter content using markdown..."}}}setupEventHandlers(e,t,n,{onSave:r,onCancel:i}){const s=e.querySelector("textarea"),o=e.querySelector(".insertr-btn-save"),a=e.querySelector(".insertr-btn-cancel");this.previewManager.setActiveContext(n),s&&s.addEventListener("input",()=>{const e=s.value;this.previewManager.schedulePreview(n,e)}),o&&o.addEventListener("click",()=>{const e=s.value;n.applyMarkdown(e),this.previewManager.clearPreview(),r({text:e}),this.close()}),a&&a.addEventListener("click",()=>{this.previewManager.clearPreview(),i(),this.close()});const l=e=>{"Escape"===e.key&&(this.previewManager.clearPreview(),i(),this.close(),document.removeEventListener("keydown",l))};document.addEventListener("keydown",l),t.addEventListener("click",e=>{e.target===t&&(this.previewManager.clearPreview(),i(),this.close())})}createOverlay(e){const t=document.createElement("div");return t.className="insertr-form-overlay",t.appendChild(e),t}positionForm(e,t){const n=e.getBoundingClientRect(),r=t.querySelector(".insertr-edit-form"),i=window.innerWidth;let s;if(i<768)s=Math.min(i-40,500);else{const e=600,t=Math.min(.9*i,800);s=Math.max(e,Math.min(1.5*n.width,t))}r.style.width=`${s}px`;const o=n.bottom+window.scrollY+10,a=n.left+window.scrollX+n.width/2-s/2,l=window.innerWidth-s-20,c=Math.max(20,Math.min(a,l));t.style.position="absolute",t.style.top=`${o}px`,t.style.left=`${c}px`,t.style.zIndex="10000",this.ensureModalVisible(e,t)}ensureModalVisible(e,t){requestAnimationFrame(()=>{const e=t.querySelector(".insertr-edit-form").getBoundingClientRect(),n=window.innerHeight;if(e.bottom>n){const t=e.bottom-n+20;window.scrollBy({top:t,behavior:"smooth"})}})}close(){this.previewManager&&this.previewManager.clearPreview(),this.currentOverlay&&(this.currentOverlay.remove(),this.currentOverlay=null)}escapeHtml(e){if("string"!=typeof e)return"";const t=document.createElement("div");return t.textContent=e,t.innerHTML}}class Ge{constructor(e){this.elements=e,this.primaryElement=e[0],this.originalContent=null}extractMarkdown(){return 1===this.elements.length?Ue.htmlToMarkdown(this.elements[0].innerHTML):Ue.extractGroupMarkdown(this.elements)}applyMarkdown(e){if(1===this.elements.length){const t=Ue.markdownToHtml(e);this.elements[0].innerHTML=t}else Ue.updateGroupElements(this.elements,e)}storeOriginalContent(){this.originalContent=this.elements.map(e=>e.innerHTML)}restoreOriginalContent(){this.originalContent&&this.elements.forEach((e,t)=>{void 0!==this.originalContent[t]&&(e.innerHTML=this.originalContent[t])})}applyPreviewStyling(){this.elements.forEach(e=>{e.classList.add("insertr-preview-active")}),this.primaryElement.classList.contains("insertr-group")&&this.primaryElement.classList.add("insertr-preview-active")}removePreviewStyling(){this.elements.forEach(e=>{e.classList.remove("insertr-preview-active")}),this.primaryElement.classList.contains("insertr-group")&&this.primaryElement.classList.remove("insertr-preview-active")}}class je{constructor(){this.previewTimeout=null,this.activeContext=null,this.resizeObserver=null}setActiveContext(e){this.clearPreview(),this.activeContext=e,this.startResizeObserver()}schedulePreview(e,t){this.previewTimeout&&clearTimeout(this.previewTimeout),this.previewTimeout=setTimeout(()=>{this.updatePreview(e,t)},500)}updatePreview(e,t){e.originalContent||e.storeOriginalContent(),e.applyMarkdown(t),e.applyPreviewStyling()}clearPreview(){this.activeContext&&(this.activeContext.restoreOriginalContent(),this.activeContext.removePreviewStyling(),this.activeContext=null),this.previewTimeout&&(clearTimeout(this.previewTimeout),this.previewTimeout=null),this.stopResizeObserver()}startResizeObserver(){this.stopResizeObserver(),this.activeContext&&(this.resizeObserver=new ResizeObserver(()=>{this.onHeightChange&&this.onHeightChange(this.activeContext.primaryElement)}),this.activeContext.elements.forEach(e=>{this.resizeObserver.observe(e)}))}stopResizeObserver(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null)}setHeightChangeCallback(e){this.onHeightChange=e}}class Ze{constructor(){this.previewTimeouts=new Map,this.activeElement=null,this.originalContent=null,this.originalStyles=null,this.resizeObserver=null,this.onHeightChangeCallback=null}schedulePreview(e,t,n){const r=this.getElementId(e);this.previewTimeouts.has(r)&&clearTimeout(this.previewTimeouts.get(r));const i=setTimeout(()=>{this.updatePreview(e,t,n)},500);this.previewTimeouts.set(r,i)}updatePreview(e,t,n){this.originalContent||this.activeElement!==e||(this.originalContent=this.extractOriginalContent(e,n)),this.applyPreviewContent(e,t,n)}extractOriginalContent(e,t){return"link"===t?{text:e.textContent,url:e.href}:e.textContent}applyPreviewContent(e,t,n){switch(e.classList.add("insertr-preview-active"),n){case"text":case"h1":case"h2":case"h3":case"h4":case"h5":case"h6":case"span":case"button":case"textarea":case"p":t&&t.trim()&&(e.textContent=t);break;case"link":"object"==typeof t?(void 0!==t.text&&t.text.trim()&&(e.textContent=t.text),void 0!==t.url&&t.url.trim()&&(e.href=t.url)):t&&t.trim()&&(e.textContent=t)}}clearPreview(e){if(!e)return;const t=this.getElementId(e);this.previewTimeouts.has(t)&&(clearTimeout(this.previewTimeouts.get(t)),this.previewTimeouts.delete(t)),this.stopResizeObserver(),this.originalContent&&e===this.activeElement&&this.restoreOriginalContent(e),e.classList.remove("insertr-preview-active"),this.activeElement=null,this.originalContent=null}restoreOriginalContent(e){this.originalContent&&("object"==typeof this.originalContent?(e.textContent=this.originalContent.text,this.originalContent.url&&(e.href=this.originalContent.url)):e.textContent=this.originalContent)}getElementId(e){return e._insertrId||(e._insertrId="insertr_"+Date.now()+"_"+Math.random().toString(36).substr(2,9)),e._insertrId}setActiveElement(e){this.activeElement=e,this.originalContent=null,this.startResizeObserver(e)}setHeightChangeCallback(e){this.onHeightChangeCallback=e}startResizeObserver(e){this.stopResizeObserver(),this.resizeObserver=new ResizeObserver(t=>{requestAnimationFrame(()=>{this.onHeightChangeCallback&&e===this.activeElement&&this.onHeightChangeCallback(e)})}),this.resizeObserver.observe(e)}stopResizeObserver(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null)}}class Qe{constructor(e=null){this.apiClient=e,this.currentOverlay=null,this.previewManager=new Ze,this.markdownEditor=new Ve,this.setupStyles()}showEditForm(e,t,n,r){this.closeForm();const{element:i,contentId:s,contentType:o}=e;if("markdown"===this.getFieldConfig(i,o).type)return this.markdownEditor.edit(i,n,r);if(i.classList.contains("insertr-group")){const e=this.getGroupChildren(i);return this.markdownEditor.edit(e,n,r)}return this.showLegacyEditForm(e,t,n,r)}showLegacyEditForm(e,t,n,r){const{element:i,contentId:s,contentType:o}=e,a=this.getFieldConfig(i,o);this.previewManager.setActiveElement(i),this.previewManager.setHeightChangeCallback(e=>{this.repositionModal(e,c)});const l=this.createEditForm(s,a,t),c=this.createOverlay(l);this.positionForm(i,c),this.setupFormHandlers(l,c,i,a,{onSave:n,onCancel:r}),document.body.appendChild(c),this.currentOverlay=c;const h=l.querySelector("input, textarea");return h&&setTimeout(()=>h.focus(),100),c}getGroupChildren(e){const t=[];for(const n of e.children)n.textContent.trim().length>0&&t.push(n);return t}closeForm(){this.markdownEditor.close(),this.previewManager.activeElement&&this.previewManager.clearPreview(this.previewManager.activeElement),this.currentOverlay&&(this.currentOverlay.remove(),this.currentOverlay=null)}getFieldConfig(e,t){const n=e.tagName.toLowerCase(),r=Array.from(e.classList);let i={h1:{type:"text",label:"Headline",maxLength:60,placeholder:"Enter headline..."},h2:{type:"text",label:"Subheading",maxLength:80,placeholder:"Enter subheading..."},h3:{type:"markdown",label:"Section Title",rows:2,placeholder:"Enter title (markdown supported)..."},h4:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},h5:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},h6:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},p:{type:"markdown",label:"Content",rows:4,placeholder:"Enter content using markdown..."},a:{type:"link",label:"Link",placeholder:"Enter link text...",includeUrl:!0},span:{type:"markdown",label:"Text",rows:2,placeholder:"Enter text (markdown supported)..."},button:{type:"text",label:"Button Text",placeholder:"Enter button text..."}}[n]||{type:"text",label:"Text",placeholder:"Enter text..."};return r.includes("lead")&&(i={...i,label:"Lead Paragraph",rows:4,placeholder:"Enter lead paragraph..."}),"markdown"===t&&(i={...i,type:"markdown",label:"Markdown Content",rows:8}),i}createEditForm(e,t,n){const r=document.createElement("div");r.className="insertr-edit-form";let i=``;return"markdown"===t.type?i+=this.createMarkdownField(t,n):"link"===t.type&&t.includeUrl?i+=this.createLinkField(t,n):"textarea"===t.type?i+=this.createTextareaField(t,n):i+=this.createTextField(t,n),i+=`\n \n Save \n Cancel \n View History \n
\n `,r.innerHTML=i,r}createMarkdownField(e,t){return`\n \n `}createLinkField(e,t){const n="object"==typeof t?t.text||"":t,r="object"==typeof t&&t.url||"";return`\n \n Link Text: \n \n
\n \n Link URL: \n \n
\n `}createTextareaField(e,t){const n="object"==typeof t?t.text||"":t;return`\n \n \n
\n `}createTextField(e,t){const n="object"==typeof t?t.text||"":t;return`\n \n \n
\n `}createOverlay(e){const t=document.createElement("div");return t.className="insertr-form-overlay",t.appendChild(e),t}getElementId(e){return e.id||e.getAttribute("data-content-id")||`element-${e.tagName}-${Date.now()}`}async showVersionHistory(e,t,n){try{const t=this.getApiClient(),r=await t.getContentVersions(e),i=this.createVersionHistoryModal(e,r,n);document.body.appendChild(i),this.setupVersionHistoryHandlers(i,e)}catch(e){console.error("Failed to load version history:",e),this.showVersionHistoryError("Failed to load version history. Please try again.")}}createVersionHistoryModal(e,t,n){const r=document.createElement("div");r.className="insertr-version-modal";let i="";return i=t&&t.length>0?t.map((e,n)=>`\n \n
\n ${0===n?"Previous Version":"Version "+(t.length-n)} \n ${this.formatDate(e.created_at)} \n ${e.created_by?`by ${e.created_by} `:""}\n
\n
${this.escapeHtml(this.truncateContent(e.value,100))}
\n
\n Restore \n View Full \n
\n
\n `).join(""):'No previous versions found
',r.innerHTML=`\n \n `,r}setupVersionHistoryHandlers(e,t){const n=e.querySelector(".insertr-btn-close"),r=e.querySelector(".insertr-version-backdrop");n&&n.addEventListener("click",()=>e.remove()),r.addEventListener("click",t=>{t.target===r&&e.remove()});e.querySelectorAll(".insertr-btn-restore").forEach(n=>{n.addEventListener("click",async()=>{const r=n.getAttribute("data-version-id");await this.confirmRestore()&&(await this.restoreVersion(t,r),e.remove(),this.closeForm())})});e.querySelectorAll(".insertr-btn-view-diff").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-version-id");this.showVersionDetails(t)})})}formatDate(e){const t=new Date(e),n=new Date-t;if(n<864e5){const e=Math.floor(n/36e5);if(e<1){return`${Math.floor(n/6e4)}m ago`}return`${e}h ago`}if(n<6048e5){return`${Math.floor(n/864e5)}d ago`}return t.toLocaleDateString()}truncateContent(e,t){return e.length<=t?e:e.substring(0,t)+"..."}async confirmRestore(){return confirm("Are you sure you want to restore this version? This will replace the current content.")}async restoreVersion(e,t){try{const n=this.getApiClient();return await n.rollbackContent(e,t),!0}catch(e){return console.error("Failed to restore version:",e),alert("Failed to restore version. Please try again."),!1}}showVersionDetails(e){alert(`Version details not implemented yet (Version ID: ${e})`)}showVersionHistoryError(e){alert(e)}getApiClient(){return this.apiClient||window.insertrAPIClient||null}repositionModal(e,t){requestAnimationFrame(()=>{const n=e.getBoundingClientRect();t.querySelector(".insertr-edit-form");const r=n.bottom+window.scrollY+10;t.style.top=`${r}px`,this.ensureModalVisible(e,t)})}ensureModalVisible(e,t){requestAnimationFrame(()=>{const e=t.querySelector(".insertr-edit-form").getBoundingClientRect(),n=window.innerHeight,r=e.bottom;if(r>n){const e=r-n+20;window.scrollBy({top:e,behavior:"smooth"})}})}setupFormHandlers(e,t,n,r,{onSave:i,onCancel:s}){const o=e.querySelector(".insertr-btn-save"),a=e.querySelector(".insertr-btn-cancel"),l=this.getElementType(n,r);this.setupLivePreview(e,n,l),o&&o.addEventListener("click",()=>{this.previewManager.clearPreview(n);const t=this.extractFormData(e);i(t),this.closeForm()}),a&&a.addEventListener("click",()=>{this.previewManager.clearPreview(n),s(),this.closeForm()});const c=e.querySelector(".insertr-btn-history");c&&c.addEventListener("click",()=>{const e=c.getAttribute("data-content-id");this.showVersionHistory(e,n,i)});const h=e=>{"Escape"===e.key&&(this.previewManager.clearPreview(n),s(),this.closeForm(),document.removeEventListener("keydown",h))};document.addEventListener("keydown",h),t.addEventListener("click",e=>{e.target===t&&(this.previewManager.clearPreview(n),s(),this.closeForm())})}setupLivePreview(e,t,n){e.querySelectorAll("input, textarea").forEach(r=>{r.addEventListener("input",()=>{const r=this.extractInputValue(e,n);this.previewManager.schedulePreview(t,r,n)})})}extractInputValue(e,t){const n=e.querySelector('input[name="text"]'),r=e.querySelector('input[name="url"]'),i=e.querySelector('input[name="content"], textarea[name="content"]');return n&&r?{text:n.value,url:r.value}:i?i.value:""}getElementType(e,t){if("link"===t.type)return"link";if("markdown"===t.type)return"markdown";if("textarea"===t.type)return"textarea";return"p"===e.tagName.toLowerCase()?"p":"text"}extractFormData(e){const t={},n=e.querySelector('input[name="text"]'),r=e.querySelector('input[name="url"]'),i=e.querySelector('input[name="content"], textarea[name="content"]');return n&&r?(t.text=n.value,t.url=r.value):i&&(t.text=i.value),t}escapeHtml(e){if("string"!=typeof e)return"";const t=document.createElement("div");return t.textContent=e,t.innerHTML}setupStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-form-overlay {\n position: absolute;\n z-index: 10000;\n }\n\n .insertr-edit-form {\n background: white;\n border: 2px solid #007cba;\n border-radius: 8px;\n padding: 1rem;\n box-shadow: 0 8px 25px rgba(0,0,0,0.15);\n width: 100%;\n box-sizing: border-box;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n .insertr-form-header {\n font-weight: 600;\n color: #1f2937;\n margin-bottom: 1rem;\n padding-bottom: 0.5rem;\n border-bottom: 1px solid #e5e7eb;\n font-size: 0.875rem;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .insertr-form-group {\n margin-bottom: 1rem;\n }\n\n .insertr-form-group:last-child {\n margin-bottom: 0;\n }\n\n .insertr-form-label {\n display: block;\n font-weight: 600;\n color: #374151;\n margin-bottom: 0.5rem;\n font-size: 0.875rem;\n }\n\n .insertr-form-input, \n .insertr-form-textarea {\n width: 100%;\n padding: 0.75rem;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n font-family: inherit;\n font-size: 1rem;\n transition: border-color 0.2s, box-shadow 0.2s;\n box-sizing: border-box;\n }\n\n .insertr-form-input:focus,\n .insertr-form-textarea:focus {\n outline: none;\n border-color: #007cba;\n box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);\n }\n\n .insertr-form-textarea {\n min-height: 120px;\n resize: vertical;\n font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;\n }\n\n .insertr-markdown-editor {\n min-height: 200px;\n font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;\n font-size: 0.9rem;\n line-height: 1.5;\n background-color: #f8fafc;\n }\n\n .insertr-form-actions {\n display: flex;\n gap: 0.5rem;\n justify-content: flex-end;\n margin-top: 1rem;\n padding-top: 1rem;\n border-top: 1px solid #e5e7eb;\n }\n\n .insertr-btn-save {\n background: #10b981;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 6px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n font-size: 0.875rem;\n }\n\n .insertr-btn-save:hover {\n background: #059669;\n }\n\n .insertr-btn-cancel {\n background: #6b7280;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 6px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n font-size: 0.875rem;\n }\n\n .insertr-btn-cancel:hover {\n background: #4b5563;\n }\n\n .insertr-form-help {\n font-size: 0.75rem;\n color: #6b7280;\n margin-top: 0.25rem;\n }\n\n /* Live Preview Styles */\n .insertr-preview-active {\n position: relative;\n background: rgba(0, 124, 186, 0.05) !important;\n outline: 2px solid #007cba !important;\n outline-offset: 2px;\n transition: all 0.3s ease;\n }\n\n .insertr-preview-active::after {\n content: \"Preview\";\n position: absolute;\n top: -25px;\n left: 0;\n background: #007cba;\n color: white;\n padding: 2px 8px;\n border-radius: 3px;\n font-size: 0.75rem;\n font-weight: 500;\n z-index: 10001;\n white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n /* Enhanced modal sizing for comfortable editing */\n .insertr-edit-form {\n min-width: 600px; /* Ensures ~70 character width */\n max-width: 800px;\n }\n\n @media (max-width: 768px) {\n .insertr-edit-form {\n min-width: 90vw;\n max-width: 90vw;\n }\n \n .insertr-preview-active::after {\n top: -20px;\n font-size: 0.7rem;\n padding: 1px 6px;\n }\n }\n\n /* Enhanced input styling for comfortable editing */\n .insertr-form-input {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;\n letter-spacing: 0.02em;\n }\n ",document.head.appendChild(e)}}class We{constructor(e,t,n,r={}){this.core=e,this.auth=t,this.apiClient=n,this.options=r,this.isActive=!1,this.formRenderer=new Qe(n)}start(){if(this.isActive)return;console.log("🚀 Starting Insertr Editor"),this.isActive=!0,this.addEditorStyles();const e=this.core.getAllElements();console.log(`📝 Found ${e.length} editable elements`),e.forEach(e=>this.initializeElement(e))}initializeElement(e){const{element:t,contentId:n,contentType:r}=e;t.style.cursor="pointer",t.style.position="relative",this.addHoverEffects(t),this.addClickHandler(t,e)}addHoverEffects(e){e.addEventListener("mouseenter",()=>{e.classList.add("insertr-editing-hover")}),e.addEventListener("mouseleave",()=>{e.classList.remove("insertr-editing-hover")})}addClickHandler(e,t){e.addEventListener("click",e=>{this.auth.isAuthenticated()&&this.auth.isEditMode()&&(e.preventDefault(),this.openEditor(t))})}openEditor(e){const{element:t}=e,n=this.extractCurrentContent(t);this.formRenderer.showEditForm(e,n,t=>this.handleSave(e,t),()=>this.handleCancel(e))}extractCurrentContent(e){return"a"===e.tagName.toLowerCase()?{text:e.textContent.trim(),url:e.getAttribute("href")||""}:e.textContent.trim()}async handleSave(e,t){console.log("💾 Saving content:",e.contentId,t);try{let n;n=(e.element.tagName.toLowerCase(),t.text||t);if(!await this.apiClient.updateContent(e.contentId,n)){const t=this.determineContentType(e.element);await this.apiClient.createContent(e.contentId,n,t)||console.error("❌ Failed to save content to server:",e.contentId)}this.updateElementContent(e.element,t),this.formRenderer.closeForm(),console.log("✅ Content saved:",e.contentId,n)}catch(n){console.error("❌ Error saving content:",n),this.updateElementContent(e.element,t),this.formRenderer.closeForm()}}determineContentType(e){const t=e.tagName.toLowerCase();return"a"===t||"button"===t?"link":"p"===t||"div"===t?"markdown":"text"}handleCancel(e){console.log("❌ Edit cancelled:",e.contentId)}updateElementContent(e,t){e.classList.contains("insertr-group")||this.isMarkdownElement(e)?console.log("🔄 Skipping element update - handled by unified markdown editor"):"a"===e.tagName.toLowerCase()?(void 0!==t.text&&(e.textContent=t.text),void 0!==t.url&&e.setAttribute("href",t.url)):e.textContent=t.text||""}isMarkdownElement(e){return new Set(["p","h3","h4","h5","h6","span"]).has(e.tagName.toLowerCase())}addEditorStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML='\n .insertr-editing-hover {\n outline: 2px dashed #007cba !important;\n outline-offset: 2px !important;\n background-color: rgba(0, 124, 186, 0.05) !important;\n }\n \n .insertr:hover::after {\n content: "✏️ " attr(data-content-type);\n position: absolute;\n top: -25px;\n left: 0;\n background: #007cba;\n color: white;\n padding: 2px 6px;\n font-size: 11px;\n border-radius: 3px;\n white-space: nowrap;\n z-index: 1000;\n font-family: monospace;\n }\n\n /* Version History Modal Styles */\n .insertr-version-modal {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 10001;\n }\n\n .insertr-version-backdrop {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background-color: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n\n .insertr-version-content-modal {\n background: white;\n border-radius: 8px;\n max-width: 600px;\n width: 100%;\n max-height: 80vh;\n box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);\n display: flex;\n flex-direction: column;\n }\n\n .insertr-version-header {\n padding: 20px 20px 0;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .insertr-version-header h3 {\n margin: 0 0 20px;\n color: #333;\n font-size: 18px;\n }\n\n .insertr-btn-close {\n background: none;\n border: none;\n font-size: 24px;\n cursor: pointer;\n color: #666;\n padding: 0;\n width: 30px;\n height: 30px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .insertr-btn-close:hover {\n color: #333;\n }\n\n .insertr-version-list {\n overflow-y: auto;\n padding: 20px;\n flex: 1;\n }\n\n .insertr-version-item {\n border: 1px solid #e1e5e9;\n border-radius: 6px;\n padding: 16px;\n margin-bottom: 12px;\n background: #f8f9fa;\n }\n\n .insertr-version-meta {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 8px;\n font-size: 13px;\n }\n\n .insertr-version-label {\n font-weight: 600;\n color: #0969da;\n }\n\n .insertr-version-date {\n color: #656d76;\n }\n\n .insertr-version-user {\n color: #656d76;\n }\n\n .insertr-version-content {\n margin-bottom: 12px;\n padding: 8px;\n background: white;\n border-radius: 4px;\n font-family: monospace;\n font-size: 14px;\n color: #24292f;\n white-space: pre-wrap;\n }\n\n .insertr-version-actions {\n display: flex;\n gap: 8px;\n }\n\n .insertr-btn-restore {\n background: #0969da;\n color: white;\n border: none;\n padding: 6px 12px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n font-weight: 500;\n }\n\n .insertr-btn-restore:hover {\n background: #0860ca;\n }\n\n .insertr-btn-view-diff {\n background: #f6f8fa;\n color: #24292f;\n border: 1px solid #d1d9e0;\n padding: 6px 12px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n font-weight: 500;\n }\n\n .insertr-btn-view-diff:hover {\n background: #f3f4f6;\n }\n\n .insertr-version-empty {\n text-align: center;\n color: #656d76;\n font-style: italic;\n padding: 40px 20px;\n }\n\n /* History Button in Form */\n .insertr-btn-history {\n background: #6f42c1;\n color: white;\n border: none;\n padding: 8px 16px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 14px;\n font-weight: 500;\n margin-left: auto;\n }\n\n .insertr-btn-history:hover {\n background: #5a359a;\n }\n ',document.head.appendChild(e)}}class Xe{constructor(e={}){this.options={mockAuth:!1!==e.mockAuth,hideGatesAfterAuth:!0===e.hideGatesAfterAuth,...e},this.state={isAuthenticated:!1,editMode:!1,currentUser:null,activeEditor:null,isInitialized:!1,isAuthenticating:!1},this.statusIndicator=null}init(){console.log("🔧 Insertr: Scanning for editor gates"),this.setupEditorGates()}initializeFullSystem(){this.state.isInitialized||(console.log("🔐 Initializing Insertr Editing System"),this.createAuthControls(),this.setupAuthenticationControls(),this.createStatusIndicator(),this.updateBodyClasses(),this.state.editMode=!0,this.state.isInitialized=!0,window.Insertr&&window.Insertr.startEditor&&window.Insertr.startEditor(),this.updateButtonStates(),this.updateStatusIndicator(),console.log("📱 Editing system active - Controls in bottom-right corner"),console.log("✏️ Edit mode enabled - Click elements to edit"))}setupEditorGates(){const e=document.querySelectorAll(".insertr-gate");0!==e.length?(console.log(`🚪 Found ${e.length} editor gate(s)`),this.addGateStyles(),e.forEach((e,t)=>{e.hasAttribute("data-original-text")||e.setAttribute("data-original-text",e.textContent),e.addEventListener("click",n=>{n.preventDefault(),this.handleGateClick(e,t)}),e.style.cursor="pointer"})):console.log("ℹ️ No .insertr-gate elements found - editor access disabled")}async handleGateClick(e,t){if(this.state.isAuthenticating)return void console.log("⏳ Authentication already in progress...");console.log(`🚀 Editor gate activated (gate ${t+1})`),this.state.isAuthenticating=!0;const n=e.textContent;e.setAttribute("data-original-text",n),e.textContent="⏳ Signing in...",e.style.pointerEvents="none";try{await this.performOAuthFlow(),this.initializeFullSystem(),this.options.hideGatesAfterAuth?this.hideAllGates():this.updateGateState()}catch(t){console.error("❌ Authentication failed:",t);const n=e.getAttribute("data-original-text");n&&(e.textContent=n),e.style.pointerEvents=""}finally{this.state.isAuthenticating=!1}}async performOAuthFlow(){if(this.options.mockAuth)return console.log("🔐 Mock OAuth: Simulating authentication..."),await new Promise(e=>setTimeout(e,1e3)),this.state.isAuthenticated=!0,this.state.currentUser={name:"Site Owner",email:"owner@example.com",role:"admin"},void console.log("✅ Mock OAuth: Authentication successful");throw new Error("Production OAuth not implemented yet")}hideAllGates(){document.body.classList.add("insertr-hide-gates"),console.log("🚪 Editor gates hidden (hideGatesAfterAuth enabled)")}updateGateState(){document.querySelectorAll(".insertr-gate").forEach(e=>{const t=e.getAttribute("data-original-text");t&&(e.textContent=t),e.style.pointerEvents="",e.style.opacity=""}),console.log("🚪 Editor gates restored to original state")}createAuthControls(){if(document.getElementById("insertr-auth-controls"))return;document.body.insertAdjacentHTML("beforeend",'\n \n Login as Client \n Edit Mode: Off \n
\n '),this.addControlStyles()}setupAuthenticationControls(){const e=document.getElementById("insertr-auth-toggle"),t=document.getElementById("insertr-edit-toggle");e&&e.addEventListener("click",()=>this.toggleAuthentication()),t&&t.addEventListener("click",()=>this.toggleEditMode())}toggleAuthentication(){this.state.isAuthenticated=!this.state.isAuthenticated,this.state.currentUser=this.state.isAuthenticated?{name:"Demo User",email:"demo@example.com",role:"editor"}:null,this.state.isAuthenticated||(this.state.editMode=!1),this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log(this.state.isAuthenticated?"✅ Authenticated as Demo User":"❌ Logged out")}toggleEditMode(){this.state.isAuthenticated?(this.state.editMode=!this.state.editMode,!this.state.editMode&&this.state.activeEditor&&(this.state.activeEditor=null),this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log(this.state.editMode?"✏️ Edit mode ON - Click elements to edit":"👀 Edit mode OFF - Read-only view")):console.warn("❌ Cannot enable edit mode - not authenticated")}updateBodyClasses(){document.body.classList.toggle("insertr-authenticated",this.state.isAuthenticated),document.body.classList.toggle("insertr-edit-mode",this.state.editMode)}updateButtonStates(){const e=document.getElementById("insertr-auth-toggle"),t=document.getElementById("insertr-edit-toggle");e&&(e.textContent=this.state.isAuthenticated?"Logout":"Login as Client",e.className="insertr-auth-btn "+(this.state.isAuthenticated?"insertr-authenticated":"")),t&&(t.style.display=this.state.isAuthenticated?"inline-block":"none",t.textContent="Edit Mode: "+(this.state.editMode?"On":"Off"),t.className="insertr-auth-btn "+(this.state.editMode?"insertr-edit-active":""))}createStatusIndicator(){if(document.getElementById("insertr-status"))return;document.body.insertAdjacentHTML("beforeend",'\n \n
\n Visitor Mode \n \n
\n
\n '),this.statusIndicator=document.getElementById("insertr-status"),this.updateStatusIndicator()}updateStatusIndicator(){const e=document.querySelector(".insertr-status-text"),t=document.querySelector(".insertr-status-dot");e&&t&&(this.state.isAuthenticated?this.state.editMode?(e.textContent="Editing",t.className="insertr-status-dot insertr-status-editing"):(e.textContent="Authenticated",t.className="insertr-status-dot insertr-status-authenticated"):(e.textContent="Visitor Mode",t.className="insertr-status-dot insertr-status-visitor"))}isAuthenticated(){return this.state.isAuthenticated}isEditMode(){return this.state.editMode}getCurrentUser(){return this.state.currentUser}addGateStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-gate {\n transition: opacity 0.2s ease;\n user-select: none;\n }\n\n .insertr-gate:hover {\n opacity: 0.7;\n }\n\n /* Optional: Hide gates when authenticated (only if hideGatesAfterAuth option is true) */\n body.insertr-hide-gates .insertr-gate {\n display: none !important;\n }\n ",document.head.appendChild(e)}addControlStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-auth-controls {\n position: fixed;\n bottom: 20px;\n right: 20px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 8px;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n .insertr-auth-btn {\n background: #4f46e5;\n color: white;\n border: none;\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n }\n\n .insertr-auth-btn:hover {\n background: #4338ca;\n transform: translateY(-1px);\n box-shadow: 0 4px 8px rgba(0,0,0,0.15);\n }\n\n .insertr-auth-btn.insertr-authenticated {\n background: #059669;\n }\n\n .insertr-auth-btn.insertr-authenticated:hover {\n background: #047857;\n }\n\n .insertr-auth-btn.insertr-edit-active {\n background: #dc2626;\n }\n\n .insertr-auth-btn.insertr-edit-active:hover {\n background: #b91c1c;\n }\n\n .insertr-status {\n position: fixed;\n bottom: 20px;\n left: 20px;\n z-index: 9999;\n background: white;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n padding: 8px 12px;\n box-shadow: 0 4px 12px rgba(0,0,0,0.1);\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n max-width: 200px;\n }\n\n .insertr-status-content {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .insertr-status-text {\n font-size: 12px;\n font-weight: 500;\n color: #374151;\n }\n\n .insertr-status-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #9ca3af;\n }\n\n .insertr-status-dot.insertr-status-visitor {\n background: #9ca3af;\n }\n\n .insertr-status-dot.insertr-status-authenticated {\n background: #059669;\n }\n\n .insertr-status-dot.insertr-status-editing {\n background: #dc2626;\n animation: insertr-pulse 2s infinite;\n }\n\n @keyframes insertr-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.5; }\n }\n\n /* Hide editing interface when not in edit mode */\n body:not(.insertr-edit-mode) .insertr:hover::after {\n display: none !important;\n }\n\n /* Only show editing features when in edit mode */\n .insertr-authenticated.insertr-edit-mode .insertr {\n cursor: pointer;\n }\n\n .insertr-authenticated.insertr-edit-mode .insertr:hover {\n outline: 2px dashed #007cba !important;\n outline-offset: 2px !important;\n background-color: rgba(0, 124, 186, 0.05) !important;\n }\n ",document.head.appendChild(e)}async authenticateWithOAuth(e="google"){console.log(`🔐 Mock OAuth login with ${e}`),setTimeout(()=>{this.state.isAuthenticated=!0,this.state.currentUser={name:"OAuth User",email:"user@example.com",provider:e,role:"editor"},this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log("✅ OAuth authentication successful")},1e3)}}class Ye{constructor(e={}){const t="localhost"===window.location.hostname||"127.0.0.1"===window.location.hostname,n=t?"http://localhost:8080/api/content":"/api/content";this.baseUrl=e.apiEndpoint||n,this.siteId=e.siteId||"demo",t&&!e.apiEndpoint&&console.log(`🔌 API Client: Using development server at ${this.baseUrl}`)}async getContent(e){try{const t=await fetch(`${this.baseUrl}/${e}?site_id=${this.siteId}`);return t.ok?await t.json():null}catch(t){return console.warn("Failed to fetch content:",e,t),null}}async updateContent(e,t){try{const n=await fetch(`${this.baseUrl}/${e}?site_id=${this.siteId}`,{method:"PUT",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({value:t})});return n.ok?(console.log(`✅ Content updated: ${e}`),!0):(console.warn(`⚠️ Update failed (${n.status}): ${e}`),!1)}catch(t){return"TypeError"===t.name&&t.message.includes("fetch")?(console.warn(`🔌 API Server not reachable at ${this.baseUrl}`),console.warn("💡 Start full-stack development: just dev")):console.error("Failed to update content:",e,t),!1}}async createContent(e,t,n){try{const r=await fetch(`${this.baseUrl}?site_id=${this.siteId}`,{method:"POST",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({id:e,value:t,type:n})});return r.ok?(console.log(`✅ Content created: ${e} (${n})`),!0):(console.warn(`⚠️ Create failed (${r.status}): ${e}`),!1)}catch(t){return"TypeError"===t.name&&t.message.includes("fetch")?(console.warn(`🔌 API Server not reachable at ${this.baseUrl}`),console.warn("💡 Start full-stack development: just dev")):console.error("Failed to create content:",e,t),!1}}async getContentVersions(e){try{const t=await fetch(`${this.baseUrl}/${e}/versions?site_id=${this.siteId}`);if(t.ok){return(await t.json()).versions||[]}return console.warn(`⚠️ Failed to fetch versions (${t.status}): ${e}`),[]}catch(t){return console.error("Failed to fetch version history:",e,t),[]}}async rollbackContent(e,t){try{const n=await fetch(`${this.baseUrl}/${e}/rollback?site_id=${this.siteId}`,{method:"POST",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({version_id:t})});return n.ok?(console.log(`✅ Content rolled back: ${e} to version ${t}`),await n.json()):(console.warn(`⚠️ Rollback failed (${n.status}): ${e}`),!1)}catch(t){return console.error("Failed to rollback content:",e,t),!1}}getCurrentUser(){return"anonymous"}}function Ke(){document.querySelector(".insertr")&&window.Insertr.init()}return window.Insertr={core:null,editor:null,auth:null,apiClient:null,init(t={}){return console.log("🔧 Insertr v1.0.0 initializing... (Hot Reload Ready)"),this.core=new e(t),this.auth=new Xe(t),this.apiClient=new Ye(t),this.editor=new We(this.core,this.auth,this.apiClient,t),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>this.start()):this.start(),this},start(){this.auth&&this.auth.init()},startEditor(){this.editor&&!this.editor.isActive&&this.editor.start()},login(){return this.auth?this.auth.toggleAuthentication():null},logout(){this.auth&&this.auth.isAuthenticated()&&this.auth.toggleAuthentication()},toggleEditMode(){return this.auth?this.auth.toggleEditMode():null},isAuthenticated(){return!!this.auth&&this.auth.isAuthenticated()},isEditMode(){return!!this.auth&&this.auth.isEditMode()}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",Ke):Ke(),window.Insertr}();
diff --git a/insertr-server/README.md b/insertr-server/README.md
index c250fa4..86e98a3 100644
--- a/insertr-server/README.md
+++ b/insertr-server/README.md
@@ -1,158 +1,123 @@
# Insertr Content Server
-The HTTP API server that provides content storage and retrieval for the Insertr CMS system.
+REST API server for the Insertr CMS system. Provides content management with version control and user attribution.
-## 🚀 Quick Start
+## Features
-### Build and Run
-```bash
-# Build the server
-go build -o insertr-server ./cmd/server
+- **Content Management**: Full CRUD operations for content items
+- **Version Control**: Complete edit history with rollback functionality
+- **User Attribution**: Track who made each change
+- **Type-Safe Database**: Uses sqlc for generated Go code from SQL
+- **SQLite & PostgreSQL**: Database flexibility for development to production
-# Start with default settings
-./insertr-server
+## API Endpoints
-# Start with custom port and database
-./insertr-server --port 8080 --db ./content.db
-```
-
-### Development
-```bash
-# Install dependencies
-go mod tidy
-
-# Run directly with go
-go run ./cmd/server --port 8080
-```
-
-## 📊 API Endpoints
-
-The server implements the exact API contract expected by both the Go CLI client and JavaScript browser client:
-
-### Content Retrieval
+### Content Operations
- `GET /api/content?site_id={site}` - Get all content for a site
- `GET /api/content/{id}?site_id={site}` - Get single content item
-- `GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}` - Get multiple items
-
-### Content Modification
+- `GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}` - Get multiple content items
- `POST /api/content` - Create new content
-- `PUT /api/content/{id}?site_id={site}` - Update existing content
+- `PUT /api/content/{id}?site_id={site}` - Update existing content
+- `DELETE /api/content/{id}?site_id={site}` - Delete content
-### System
-- `GET /health` - Health check endpoint
+### Version Control
+- `GET /api/content/{id}/versions?site_id={site}` - Get version history
+- `POST /api/content/{id}/rollback?site_id={site}` - Rollback to specific version
-## 🗄️ Database
+### Health & Status
+- `GET /health` - Server health check
-Uses SQLite by default for simplicity. The database schema:
+## User Attribution
-```sql
-CREATE TABLE content (
- id TEXT NOT NULL,
- site_id TEXT NOT NULL,
- value TEXT NOT NULL,
- type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (id, site_id)
-);
+All content operations support user attribution via the `X-User-ID` header:
+
+```bash
+curl -X PUT "http://localhost:8080/api/content/hero-title?site_id=demo" \
+ -H "Content-Type: application/json" \
+ -H "X-User-ID: john@example.com" \
+ -d '{"value": "Updated content"}'
```
-## 🔧 Configuration
+## Quick Start
-### Command Line Options
-- `--port` - Server port (default: 8080)
-- `--db` - SQLite database path (default: ./insertr.db)
+```bash
+# Build server
+go build -o insertr-server ./cmd/server
-### CORS
-Currently configured for development with `Access-Control-Allow-Origin: *`.
-For production, configure CORS appropriately.
+# Start server
+./insertr-server --port 8080
-## 🧪 Testing
+# Check health
+curl http://localhost:8080/health
+```
-### API Testing Examples
+## Development
+
+### Using sqlc
+
+```bash
+# Install sqlc
+go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
+
+# Generate Go code from SQL
+sqlc generate
+
+# Build with generated code
+go build ./cmd/server
+```
+
+### Database Schema
+
+See `db/schema/schema.sql` for the complete schema. Key tables:
+
+- `content` - Current content versions
+- `content_versions` - Complete version history
+
+### Example Version Control Workflow
```bash
# Create content
curl -X POST "http://localhost:8080/api/content" \
-H "Content-Type: application/json" \
- -d '{"id":"hero-title","value":"Welcome!","type":"text"}'
+ -H "X-User-ID: alice@example.com" \
+ -d '{
+ "id": "hero-title",
+ "site_id": "demo",
+ "value": "Original Title",
+ "type": "text"
+ }'
-# Get content
-curl "http://localhost:8080/api/content/hero-title?site_id=demo"
-
-# Update content
+# Update content (creates version)
curl -X PUT "http://localhost:8080/api/content/hero-title?site_id=demo" \
-H "Content-Type: application/json" \
- -d '{"value":"Updated Welcome!"}'
+ -H "X-User-ID: bob@example.com" \
+ -d '{"value": "Updated Title"}'
+
+# View version history
+curl "http://localhost:8080/api/content/hero-title/versions?site_id=demo"
+
+# Rollback to version 1
+curl -X POST "http://localhost:8080/api/content/hero-title/rollback?site_id=demo" \
+ -H "Content-Type: application/json" \
+ -H "X-User-ID: admin@example.com" \
+ -d '{"version_id": 1}'
```
-### Integration Testing
-```bash
-# From project root
-./test-integration.sh
-```
-
-## 🏗️ Architecture Integration
-
-This server bridges the gap between:
-
-1. **Browser Editor** (`lib/`) - JavaScript client that saves edits
-2. **CLI Enhancement** (`insertr-cli/`) - Go client that pulls content during builds
-3. **Static Site Generation** - Enhanced HTML with database content
-
-### Content Flow
-```
-Browser Edit → HTTP Server → SQLite Database
- ↓
-CLI Build Process ← HTTP Server ← SQLite Database
- ↓
-Enhanced Static Site
-```
-
-## 🚀 Production Deployment
-
-### Docker (Recommended)
-```dockerfile
-FROM golang:1.21-alpine AS builder
-WORKDIR /app
-COPY . .
-RUN go build -o insertr-server ./cmd/server
-
-FROM alpine:latest
-RUN apk --no-cache add ca-certificates
-WORKDIR /root/
-COPY --from=builder /app/insertr-server .
-EXPOSE 8080
-CMD ["./insertr-server"]
-```
+## Configuration
### Environment Variables
-- `PORT` - Server port
-- `DB_PATH` - Database file path
-- `CORS_ORIGIN` - Allowed CORS origin for production
+- `PORT` - Server port (default: 8080)
+- `DB_PATH` - SQLite database file path (default: ./insertr.db)
-### Health Monitoring
-The `/health` endpoint returns JSON status for monitoring:
-```json
-{"status":"healthy","service":"insertr-server"}
+### Command Line Flags
+```bash
+./insertr-server --help
```
-## 🔐 Security Considerations
+## Production Deployment
-### Current State (Development)
-- Open CORS policy
-- No authentication required
-- SQLite database (single file)
-
-### Production TODO
-- [ ] JWT/OAuth authentication
-- [ ] PostgreSQL database option
-- [ ] Rate limiting
-- [ ] Input validation and sanitization
-- [ ] HTTPS enforcement
-- [ ] Configurable CORS origins
-
----
-
-**Status**: ✅ Fully functional development server
-**Next**: Production hardening and authentication
\ No newline at end of file
+1. **Database**: Consider PostgreSQL for production scale
+2. **Authentication**: Integrate with your auth system via middleware
+3. **CORS**: Configure appropriate CORS policies
+4. **SSL**: Serve over HTTPS
+5. **Monitoring**: Add logging and metrics collection
\ No newline at end of file
diff --git a/insertr-server/cmd/server/main.go b/insertr-server/cmd/server/main.go
index a485d90..6f37463 100644
--- a/insertr-server/cmd/server/main.go
+++ b/insertr-server/cmd/server/main.go
@@ -23,7 +23,7 @@ func main() {
flag.Parse()
// Initialize database
- database, err := db.NewSQLiteDB(*dbPath)
+ database, err := db.NewDatabase(*dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
@@ -48,12 +48,17 @@ func main() {
// Content endpoints matching the expected API contract
apiRouter.HandleFunc("/bulk", contentHandler.GetBulkContent).Methods("GET")
+ apiRouter.HandleFunc("/{id}/versions", contentHandler.GetContentVersions).Methods("GET")
+ apiRouter.HandleFunc("/{id}/rollback", contentHandler.RollbackContent).Methods("POST")
apiRouter.HandleFunc("/{id}", contentHandler.GetContent).Methods("GET")
apiRouter.HandleFunc("/{id}", contentHandler.UpdateContent).Methods("PUT")
+ apiRouter.HandleFunc("/{id}", contentHandler.DeleteContent).Methods("DELETE")
apiRouter.HandleFunc("", contentHandler.GetAllContent).Methods("GET")
apiRouter.HandleFunc("", contentHandler.CreateContent).Methods("POST")
// Handle CORS preflight requests explicitly
+ apiRouter.HandleFunc("/{id}/versions", api.CORSPreflightHandler).Methods("OPTIONS")
+ apiRouter.HandleFunc("/{id}/rollback", api.CORSPreflightHandler).Methods("OPTIONS")
apiRouter.HandleFunc("/{id}", api.CORSPreflightHandler).Methods("OPTIONS")
apiRouter.HandleFunc("", api.CORSPreflightHandler).Methods("OPTIONS")
apiRouter.HandleFunc("/bulk", api.CORSPreflightHandler).Methods("OPTIONS")
@@ -68,8 +73,11 @@ func main() {
fmt.Printf(" GET /api/content?site_id={site}\n")
fmt.Printf(" GET /api/content/{id}?site_id={site}\n")
fmt.Printf(" GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}\n")
+ fmt.Printf(" GET /api/content/{id}/versions?site_id={site}\n")
fmt.Printf(" POST /api/content\n")
fmt.Printf(" PUT /api/content/{id}\n")
+ fmt.Printf(" POST /api/content/{id}/rollback\n")
+ fmt.Printf(" DELETE /api/content/{id}?site_id={site}\n")
fmt.Printf("\n🔄 Press Ctrl+C to shutdown gracefully\n\n")
// Setup graceful shutdown
diff --git a/insertr-server/db/postgresql/schema.sql b/insertr-server/db/postgresql/schema.sql
new file mode 100644
index 0000000..b64133f
--- /dev/null
+++ b/insertr-server/db/postgresql/schema.sql
@@ -0,0 +1,42 @@
+-- PostgreSQL-specific schema with BIGINT UNIX timestamps
+-- Main content table (current versions only)
+CREATE TABLE content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
+ updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+);
+
+-- Version history table for rollback functionality
+CREATE TABLE content_versions (
+ version_id SERIAL PRIMARY KEY,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
+CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);
+CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);
+
+-- Function and trigger to automatically update updated_at timestamp
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = EXTRACT(EPOCH FROM NOW());
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+CREATE TRIGGER update_content_updated_at
+ BEFORE UPDATE ON content
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
\ No newline at end of file
diff --git a/insertr-server/db/postgresql/setup.sql b/insertr-server/db/postgresql/setup.sql
new file mode 100644
index 0000000..e9dcd2f
--- /dev/null
+++ b/insertr-server/db/postgresql/setup.sql
@@ -0,0 +1,47 @@
+-- name: InitializeSchema :exec
+CREATE TABLE IF NOT EXISTS content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+);
+
+-- name: InitializeVersionsTable :exec
+CREATE TABLE IF NOT EXISTS content_versions (
+ version_id SERIAL PRIMARY KEY,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+);
+
+-- name: CreateContentSiteIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
+
+-- name: CreateContentUpdatedAtIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);
+
+-- name: CreateVersionsLookupIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);
+
+-- name: CreateUpdateFunction :exec
+CREATE OR REPLACE FUNCTION update_content_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = EXTRACT(EPOCH FROM NOW());
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- name: CreateUpdateTrigger :exec
+DROP TRIGGER IF EXISTS update_content_updated_at ON content;
+CREATE TRIGGER update_content_updated_at
+BEFORE UPDATE ON content
+FOR EACH ROW
+EXECUTE FUNCTION update_content_timestamp();
\ No newline at end of file
diff --git a/insertr-server/db/queries/content.sql b/insertr-server/db/queries/content.sql
new file mode 100644
index 0000000..0bc22ff
--- /dev/null
+++ b/insertr-server/db/queries/content.sql
@@ -0,0 +1,30 @@
+-- name: GetContent :one
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
+
+-- name: GetAllContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = sqlc.arg(site_id)
+ORDER BY updated_at DESC;
+
+-- name: GetBulkContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = sqlc.arg(site_id) AND id IN (sqlc.slice('ids'));
+
+-- name: CreateContent :one
+INSERT INTO content (id, site_id, value, type, last_edited_by)
+VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by))
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
+
+-- name: UpdateContent :one
+UPDATE content
+SET value = sqlc.arg(value), type = sqlc.arg(type), last_edited_by = sqlc.arg(last_edited_by)
+WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id)
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
+
+-- name: DeleteContent :exec
+DELETE FROM content
+WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
\ No newline at end of file
diff --git a/insertr-server/db/queries/versions.sql b/insertr-server/db/queries/versions.sql
new file mode 100644
index 0000000..1339907
--- /dev/null
+++ b/insertr-server/db/queries/versions.sql
@@ -0,0 +1,29 @@
+-- name: CreateContentVersion :exec
+INSERT INTO content_versions (content_id, site_id, value, type, created_by)
+VALUES (sqlc.arg(content_id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(created_by));
+
+-- name: GetContentVersionHistory :many
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE content_id = sqlc.arg(content_id) AND site_id = sqlc.arg(site_id)
+ORDER BY created_at DESC
+LIMIT sqlc.arg(limit_count);
+
+-- name: GetContentVersion :one
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE version_id = sqlc.arg(version_id);
+
+-- name: GetAllVersionsForSite :many
+SELECT
+ cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
+ c.value as current_value
+FROM content_versions cv
+LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
+WHERE cv.site_id = sqlc.arg(site_id)
+ORDER BY cv.created_at DESC
+LIMIT sqlc.arg(limit_count);
+
+-- name: DeleteOldVersions :exec
+DELETE FROM content_versions
+WHERE created_at < sqlc.arg(created_before) AND site_id = sqlc.arg(site_id);
\ No newline at end of file
diff --git a/insertr-server/db/sqlite/schema.sql b/insertr-server/db/sqlite/schema.sql
new file mode 100644
index 0000000..88c4fe9
--- /dev/null
+++ b/insertr-server/db/sqlite/schema.sql
@@ -0,0 +1,36 @@
+-- SQLite-specific schema with INTEGER timestamps
+-- Main content table (current versions only)
+CREATE TABLE content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+);
+
+-- Version history table for rollback functionality
+CREATE TABLE content_versions (
+ version_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
+CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);
+CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);
+
+-- Trigger to automatically update updated_at timestamp
+CREATE TRIGGER IF NOT EXISTS update_content_updated_at
+AFTER UPDATE ON content
+FOR EACH ROW
+BEGIN
+ UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id;
+END;
\ No newline at end of file
diff --git a/insertr-server/db/sqlite/setup.sql b/insertr-server/db/sqlite/setup.sql
new file mode 100644
index 0000000..bfe8fcd
--- /dev/null
+++ b/insertr-server/db/sqlite/setup.sql
@@ -0,0 +1,39 @@
+-- name: InitializeSchema :exec
+CREATE TABLE IF NOT EXISTS content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+);
+
+-- name: InitializeVersionsTable :exec
+CREATE TABLE IF NOT EXISTS content_versions (
+ version_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+);
+
+-- name: CreateContentSiteIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
+
+-- name: CreateContentUpdatedAtIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);
+
+-- name: CreateVersionsLookupIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);
+
+-- name: CreateUpdateTrigger :exec
+CREATE TRIGGER IF NOT EXISTS update_content_updated_at
+AFTER UPDATE ON content
+FOR EACH ROW
+BEGIN
+ UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id;
+END;
\ No newline at end of file
diff --git a/insertr-server/go.mod b/insertr-server/go.mod
index 2d50f49..369583b 100644
--- a/insertr-server/go.mod
+++ b/insertr-server/go.mod
@@ -6,3 +6,5 @@ require (
github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.32
)
+
+require github.com/lib/pq v1.10.9 // indirect
diff --git a/insertr-server/go.sum b/insertr-server/go.sum
index 4f163af..03194b7 100644
--- a/insertr-server/go.sum
+++ b/insertr-server/go.sum
@@ -1,4 +1,6 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
diff --git a/insertr-server/insertr-server b/insertr-server/insertr-server
index 1cbd5aa..8a89f52 100755
Binary files a/insertr-server/insertr-server and b/insertr-server/insertr-server differ
diff --git a/insertr-server/internal/api/handlers.go b/insertr-server/internal/api/handlers.go
index 1b6489c..7ed901e 100644
--- a/insertr-server/internal/api/handlers.go
+++ b/insertr-server/internal/api/handlers.go
@@ -1,27 +1,34 @@
package api
import (
+ "context"
+ "database/sql"
"encoding/json"
"fmt"
"net/http"
+ "strconv"
"strings"
+ "time"
"github.com/gorilla/mux"
"github.com/insertr/server/internal/db"
- "github.com/insertr/server/internal/models"
+ "github.com/insertr/server/internal/db/postgresql"
+ "github.com/insertr/server/internal/db/sqlite"
)
// ContentHandler handles all content-related HTTP requests
type ContentHandler struct {
- db *db.SQLiteDB
+ database *db.Database
}
// NewContentHandler creates a new content handler
-func NewContentHandler(database *db.SQLiteDB) *ContentHandler {
- return &ContentHandler{db: database}
+func NewContentHandler(database *db.Database) *ContentHandler {
+ return &ContentHandler{
+ database: database,
+ }
}
-// GetContent handles GET /api/content/{id}?site_id={site}
+// GetContent handles GET /api/content/{id}
func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentID := vars["id"]
@@ -32,78 +39,119 @@ func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) {
return
}
- if contentID == "" {
- http.Error(w, "content ID is required", http.StatusBadRequest)
+ var content interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ content, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ content, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
- content, err := h.db.GetContent(siteID, contentID)
if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Content not found", http.StatusNotFound)
+ return
+ }
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
- if content == nil {
- http.NotFound(w, r)
- return
- }
+ item := h.convertToAPIContent(content)
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(content)
+ json.NewEncoder(w).Encode(item)
}
-// GetAllContent handles GET /api/content?site_id={site}
+// GetAllContent handles GET /api/content
func (h *ContentHandler) GetAllContent(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site_id")
-
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
- items, err := h.db.GetAllContent(siteID)
+ var dbContent interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ dbContent, err = h.database.GetSQLiteQueries().GetAllContent(context.Background(), siteID)
+ case "postgresql":
+ dbContent, err = h.database.GetPostgreSQLQueries().GetAllContent(context.Background(), siteID)
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
- response := models.ContentResponse{
- Content: items,
- }
+ items := h.convertToAPIContentList(dbContent)
+ response := ContentResponse{Content: items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
-// GetBulkContent handles GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}
+// GetBulkContent handles GET /api/content/bulk
func (h *ContentHandler) GetBulkContent(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site_id")
- contentIDs := r.URL.Query()["ids"]
-
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
- if len(contentIDs) == 0 {
- // Return empty response if no IDs provided
- response := models.ContentResponse{
- Content: []models.ContentItem{},
+ // Parse ids parameter
+ idsParam := r.URL.Query()["ids[]"]
+ if len(idsParam) == 0 {
+ // Try single ids parameter
+ idsStr := r.URL.Query().Get("ids")
+ if idsStr == "" {
+ http.Error(w, "ids parameter is required", http.StatusBadRequest)
+ return
}
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(response)
+ idsParam = strings.Split(idsStr, ",")
+ }
+
+ var dbContent interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ dbContent, err = h.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{
+ SiteID: siteID,
+ Ids: idsParam,
+ })
+ case "postgresql":
+ dbContent, err = h.database.GetPostgreSQLQueries().GetBulkContent(context.Background(), postgresql.GetBulkContentParams{
+ SiteID: siteID,
+ Ids: idsParam,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
- items, err := h.db.GetBulkContent(siteID, contentIDs)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
- response := models.ContentResponse{
- Content: items,
- }
+ items := h.convertToAPIContentList(dbContent)
+ response := ContentResponse{Content: items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
@@ -111,55 +159,64 @@ func (h *ContentHandler) GetBulkContent(w http.ResponseWriter, r *http.Request)
// CreateContent handles POST /api/content
func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
+ var req CreateContentRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
- siteID = "demo" // Default to demo site for compatibility
+ siteID = req.SiteID // fallback to request body
+ }
+ if siteID == "" {
+ siteID = "default" // final fallback
}
- var req models.CreateContentRequest
- decoder := json.NewDecoder(r.Body)
- if err := decoder.Decode(&req); err != nil {
- http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ // Extract user from request (for now, use X-User-ID header or fallback)
+ userID := r.Header.Get("X-User-ID")
+ if userID == "" && req.CreatedBy != "" {
+ userID = req.CreatedBy
+ }
+ if userID == "" {
+ userID = "anonymous"
+ }
+
+ var content interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ content, err = h.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
+ ID: req.ID,
+ SiteID: siteID,
+ Value: req.Value,
+ Type: req.Type,
+ LastEditedBy: userID,
+ })
+ case "postgresql":
+ content, err = h.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
+ ID: req.ID,
+ SiteID: siteID,
+ Value: req.Value,
+ Type: req.Type,
+ LastEditedBy: userID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
- // Validate content type
- validTypes := []string{"text", "markdown", "link"}
- isValidType := false
- for _, validType := range validTypes {
- if req.Type == validType {
- isValidType = true
- break
- }
- }
-
- if !isValidType {
- http.Error(w, fmt.Sprintf("Invalid content type. Must be one of: %s", strings.Join(validTypes, ", ")), http.StatusBadRequest)
- return
- }
-
- // Check if content already exists
- existing, err := h.db.GetContent(siteID, req.ID)
if err != nil {
- http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
+ http.Error(w, fmt.Sprintf("Failed to create content: %v", err), http.StatusInternalServerError)
return
}
- if existing != nil {
- http.Error(w, "Content with this ID already exists", http.StatusConflict)
- return
- }
-
- // Create content
- content, err := h.db.CreateContent(siteID, req.ID, req.Value, req.Type)
- if err != nil {
- http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
- return
- }
+ item := h.convertToAPIContent(content)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
- json.NewEncoder(w).Encode(content)
+ json.NewEncoder(w).Encode(item)
}
// UpdateContent handles PUT /api/content/{id}
@@ -169,32 +226,443 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
- siteID = "demo" // Default to demo site for compatibility
- }
-
- if contentID == "" {
- http.Error(w, "content ID is required", http.StatusBadRequest)
+ http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
- var req models.UpdateContentRequest
- decoder := json.NewDecoder(r.Body)
- if err := decoder.Decode(&req); err != nil {
- http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ var req UpdateContentRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ // Extract user from request
+ userID := r.Header.Get("X-User-ID")
+ if userID == "" && req.UpdatedBy != "" {
+ userID = req.UpdatedBy
+ }
+ if userID == "" {
+ userID = "anonymous"
+ }
+
+ // Get current content for version history and type preservation
+ var currentContent interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
- // Update content
- content, err := h.db.UpdateContent(siteID, contentID, req.Value)
if err != nil {
- if strings.Contains(err.Error(), "not found") {
- http.NotFound(w, r)
+ if err == sql.ErrNoRows {
+ http.Error(w, "Content not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
+ // Archive current version before updating
+ err = h.createContentVersion(currentContent)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Determine content type
+ contentType := req.Type
+ if contentType == "" {
+ contentType = h.getContentType(currentContent) // preserve existing type if not specified
+ }
+
+ // Update the content
+ var updatedContent interface{}
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
+ Value: req.Value,
+ Type: contentType,
+ LastEditedBy: userID,
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
+ Value: req.Value,
+ Type: contentType,
+ LastEditedBy: userID,
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ item := h.convertToAPIContent(updatedContent)
+
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(content)
+ json.NewEncoder(w).Encode(item)
+}
+
+// DeleteContent handles DELETE /api/content/{id}
+func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ contentID := vars["id"]
+ siteID := r.URL.Query().Get("site_id")
+
+ if siteID == "" {
+ http.Error(w, "site_id parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ err = h.database.GetSQLiteQueries().DeleteContent(context.Background(), sqlite.DeleteContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ err = h.database.GetPostgreSQLQueries().DeleteContent(context.Background(), postgresql.DeleteContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to delete content: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// GetContentVersions handles GET /api/content/{id}/versions
+func (h *ContentHandler) GetContentVersions(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ contentID := vars["id"]
+ siteID := r.URL.Query().Get("site_id")
+
+ if siteID == "" {
+ http.Error(w, "site_id parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ // Parse limit parameter (default to 10)
+ limit := int64(10)
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil {
+ limit = parsedLimit
+ }
+ }
+
+ var dbVersions interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ dbVersions, err = h.database.GetSQLiteQueries().GetContentVersionHistory(context.Background(), sqlite.GetContentVersionHistoryParams{
+ ContentID: contentID,
+ SiteID: siteID,
+ LimitCount: limit,
+ })
+ case "postgresql":
+ // Note: PostgreSQL uses different parameter names due to int32 vs int64
+ dbVersions, err = h.database.GetPostgreSQLQueries().GetContentVersionHistory(context.Background(), postgresql.GetContentVersionHistoryParams{
+ ContentID: contentID,
+ SiteID: siteID,
+ LimitCount: int32(limit),
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ versions := h.convertToAPIVersionList(dbVersions)
+ response := ContentVersionsResponse{Versions: versions}
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+}
+
+// RollbackContent handles POST /api/content/{id}/rollback
+func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ contentID := vars["id"]
+ siteID := r.URL.Query().Get("site_id")
+
+ if siteID == "" {
+ http.Error(w, "site_id parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ var req RollbackContentRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ // Get the target version
+ var targetVersion interface{}
+ var err error
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ targetVersion, err = h.database.GetSQLiteQueries().GetContentVersion(context.Background(), req.VersionID)
+ case "postgresql":
+ targetVersion, err = h.database.GetPostgreSQLQueries().GetContentVersion(context.Background(), int32(req.VersionID))
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Version not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Verify the version belongs to the correct content
+ if !h.versionMatches(targetVersion, contentID, siteID) {
+ http.Error(w, "Version does not match content", http.StatusBadRequest)
+ return
+ }
+
+ // Extract user from request
+ userID := r.Header.Get("X-User-ID")
+ if userID == "" && req.RolledBackBy != "" {
+ userID = req.RolledBackBy
+ }
+ if userID == "" {
+ userID = "anonymous"
+ }
+
+ // Archive current version before rollback
+ var currentContent interface{}
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to get current content: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ err = h.createContentVersion(currentContent)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Rollback to target version
+ var updatedContent interface{}
+
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ sqliteVersion := targetVersion.(sqlite.ContentVersion)
+ updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
+ Value: sqliteVersion.Value,
+ Type: sqliteVersion.Type,
+ LastEditedBy: userID,
+ ID: contentID,
+ SiteID: siteID,
+ })
+ case "postgresql":
+ pgVersion := targetVersion.(postgresql.ContentVersion)
+ updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
+ Value: pgVersion.Value,
+ Type: pgVersion.Type,
+ LastEditedBy: userID,
+ ID: contentID,
+ SiteID: siteID,
+ })
+ default:
+ http.Error(w, "Unsupported database type", http.StatusInternalServerError)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to rollback content: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ item := h.convertToAPIContent(updatedContent)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(item)
+}
+
+// Helper functions for type conversion
+func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ c := content.(sqlite.Content)
+ return ContentItem{
+ ID: c.ID,
+ SiteID: c.SiteID,
+ Value: c.Value,
+ Type: c.Type,
+ CreatedAt: time.Unix(c.CreatedAt, 0),
+ UpdatedAt: time.Unix(c.UpdatedAt, 0),
+ LastEditedBy: c.LastEditedBy,
+ }
+ case "postgresql":
+ c := content.(postgresql.Content)
+ return ContentItem{
+ ID: c.ID,
+ SiteID: c.SiteID,
+ Value: c.Value,
+ Type: c.Type,
+ CreatedAt: time.Unix(c.CreatedAt, 0),
+ UpdatedAt: time.Unix(c.UpdatedAt, 0),
+ LastEditedBy: c.LastEditedBy,
+ }
+ }
+ return ContentItem{} // Should never happen
+}
+
+func (h *ContentHandler) convertToAPIContentList(contentList interface{}) []ContentItem {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ list := contentList.([]sqlite.Content)
+ items := make([]ContentItem, len(list))
+ for i, content := range list {
+ items[i] = h.convertToAPIContent(content)
+ }
+ return items
+ case "postgresql":
+ list := contentList.([]postgresql.Content)
+ items := make([]ContentItem, len(list))
+ for i, content := range list {
+ items[i] = h.convertToAPIContent(content)
+ }
+ return items
+ }
+ return []ContentItem{} // Should never happen
+}
+
+func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []ContentVersion {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ list := versionList.([]sqlite.ContentVersion)
+ versions := make([]ContentVersion, len(list))
+ for i, version := range list {
+ versions[i] = ContentVersion{
+ VersionID: version.VersionID,
+ ContentID: version.ContentID,
+ SiteID: version.SiteID,
+ Value: version.Value,
+ Type: version.Type,
+ CreatedAt: time.Unix(version.CreatedAt, 0),
+ CreatedBy: version.CreatedBy,
+ }
+ }
+ return versions
+ case "postgresql":
+ list := versionList.([]postgresql.ContentVersion)
+ versions := make([]ContentVersion, len(list))
+ for i, version := range list {
+ versions[i] = ContentVersion{
+ VersionID: int64(version.VersionID),
+ ContentID: version.ContentID,
+ SiteID: version.SiteID,
+ Value: version.Value,
+ Type: version.Type,
+ CreatedAt: time.Unix(version.CreatedAt, 0),
+ CreatedBy: version.CreatedBy,
+ }
+ }
+ return versions
+ }
+ return []ContentVersion{} // Should never happen
+}
+
+func (h *ContentHandler) createContentVersion(content interface{}) error {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ c := content.(sqlite.Content)
+ return h.database.GetSQLiteQueries().CreateContentVersion(context.Background(), sqlite.CreateContentVersionParams{
+ ContentID: c.ID,
+ SiteID: c.SiteID,
+ Value: c.Value,
+ Type: c.Type,
+ CreatedBy: c.LastEditedBy,
+ })
+ case "postgresql":
+ c := content.(postgresql.Content)
+ return h.database.GetPostgreSQLQueries().CreateContentVersion(context.Background(), postgresql.CreateContentVersionParams{
+ ContentID: c.ID,
+ SiteID: c.SiteID,
+ Value: c.Value,
+ Type: c.Type,
+ CreatedBy: c.LastEditedBy,
+ })
+ }
+ return fmt.Errorf("unsupported database type")
+}
+
+func (h *ContentHandler) getContentType(content interface{}) string {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ return content.(sqlite.Content).Type
+ case "postgresql":
+ return content.(postgresql.Content).Type
+ }
+ return ""
+}
+
+func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID string) bool {
+ switch h.database.GetDBType() {
+ case "sqlite3":
+ v := version.(sqlite.ContentVersion)
+ return v.ContentID == contentID && v.SiteID == siteID
+ case "postgresql":
+ v := version.(postgresql.ContentVersion)
+ return v.ContentID == contentID && v.SiteID == siteID
+ }
+ return false
}
diff --git a/insertr-server/internal/api/models.go b/insertr-server/internal/api/models.go
new file mode 100644
index 0000000..7aaa220
--- /dev/null
+++ b/insertr-server/internal/api/models.go
@@ -0,0 +1,52 @@
+package api
+
+import "time"
+
+// API request/response models
+type ContentItem struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ LastEditedBy string `json:"last_edited_by"`
+}
+
+type ContentVersion struct {
+ VersionID int64 `json:"version_id"`
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt time.Time `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+}
+
+type ContentResponse struct {
+ Content []ContentItem `json:"content"`
+}
+
+type ContentVersionsResponse struct {
+ Versions []ContentVersion `json:"versions"`
+}
+
+// Request models
+type CreateContentRequest struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id,omitempty"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedBy string `json:"created_by,omitempty"`
+}
+
+type UpdateContentRequest struct {
+ Value string `json:"value"`
+ Type string `json:"type,omitempty"`
+ UpdatedBy string `json:"updated_by,omitempty"`
+}
+
+type RollbackContentRequest struct {
+ VersionID int64 `json:"version_id"`
+ RolledBackBy string `json:"rolled_back_by,omitempty"`
+}
diff --git a/insertr-server/internal/db/database.go b/insertr-server/internal/db/database.go
new file mode 100644
index 0000000..f998db5
--- /dev/null
+++ b/insertr-server/internal/db/database.go
@@ -0,0 +1,184 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+
+ _ "github.com/lib/pq"
+ _ "github.com/mattn/go-sqlite3"
+
+ "github.com/insertr/server/internal/db/postgresql"
+ "github.com/insertr/server/internal/db/sqlite"
+)
+
+// Database wraps the database connection and queries
+type Database struct {
+ conn *sql.DB
+ dbType string
+
+ // Type-specific query interfaces
+ sqliteQueries *sqlite.Queries
+ postgresqlQueries *postgresql.Queries
+}
+
+// NewDatabase creates a new database connection
+func NewDatabase(dbPath string) (*Database, error) {
+ var conn *sql.DB
+ var dbType string
+ var err error
+
+ // Determine database type from connection string
+ if strings.Contains(dbPath, "postgres://") || strings.Contains(dbPath, "postgresql://") {
+ dbType = "postgresql"
+ conn, err = sql.Open("postgres", dbPath)
+ } else {
+ dbType = "sqlite3"
+ conn, err = sql.Open("sqlite3", dbPath)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ // Test connection
+ if err := conn.Ping(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ // Initialize the appropriate queries
+ db := &Database{
+ conn: conn,
+ dbType: dbType,
+ }
+
+ switch dbType {
+ case "sqlite3":
+ // Initialize SQLite schema using generated functions
+ db.sqliteQueries = sqlite.New(conn)
+ if err := db.initializeSQLiteSchema(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("failed to initialize SQLite schema: %w", err)
+ }
+ case "postgresql":
+ // Initialize PostgreSQL schema using generated functions
+ db.postgresqlQueries = postgresql.New(conn)
+ if err := db.initializePostgreSQLSchema(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("failed to initialize PostgreSQL schema: %w", err)
+ }
+ default:
+ return nil, fmt.Errorf("unsupported database type: %s", dbType)
+ }
+
+ return db, nil
+}
+
+// Close closes the database connection
+func (db *Database) Close() error {
+ return db.conn.Close()
+}
+
+// GetQueries returns the appropriate query interface
+func (db *Database) GetSQLiteQueries() *sqlite.Queries {
+ return db.sqliteQueries
+}
+
+func (db *Database) GetPostgreSQLQueries() *postgresql.Queries {
+ return db.postgresqlQueries
+}
+
+// GetDBType returns the database type
+func (db *Database) GetDBType() string {
+ return db.dbType
+}
+
+// initializeSQLiteSchema sets up the SQLite database schema
+func (db *Database) initializeSQLiteSchema() error {
+ ctx := context.Background()
+
+ // Create tables
+ if err := db.sqliteQueries.InitializeSchema(ctx); err != nil {
+ return fmt.Errorf("failed to create content table: %w", err)
+ }
+
+ if err := db.sqliteQueries.InitializeVersionsTable(ctx); err != nil {
+ return fmt.Errorf("failed to create content_versions table: %w", err)
+ }
+
+ // Create indexes (manual for now since sqlc didn't generate them)
+ indexQueries := []string{
+ "CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);",
+ "CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);",
+ "CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);",
+ }
+
+ for _, query := range indexQueries {
+ if _, err := db.conn.Exec(query); err != nil {
+ return fmt.Errorf("failed to create index: %w", err)
+ }
+ }
+
+ // Create update trigger (manual for now)
+ triggerQuery := `
+ CREATE TRIGGER IF NOT EXISTS update_content_updated_at
+ AFTER UPDATE ON content
+ FOR EACH ROW
+ BEGIN
+ UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id;
+ END;`
+
+ if _, err := db.conn.Exec(triggerQuery); err != nil {
+ return fmt.Errorf("failed to create update trigger: %w", err)
+ }
+
+ return nil
+}
+
+// initializePostgreSQLSchema sets up the PostgreSQL database schema
+func (db *Database) initializePostgreSQLSchema() error {
+ ctx := context.Background()
+
+ // Create tables
+ if err := db.postgresqlQueries.InitializeSchema(ctx); err != nil {
+ return fmt.Errorf("failed to create content table: %w", err)
+ }
+
+ if err := db.postgresqlQueries.InitializeVersionsTable(ctx); err != nil {
+ return fmt.Errorf("failed to create content_versions table: %w", err)
+ }
+
+ // Create indexes using generated functions
+ if err := db.postgresqlQueries.CreateContentSiteIndex(ctx); err != nil {
+ return fmt.Errorf("failed to create content site index: %w", err)
+ }
+
+ if err := db.postgresqlQueries.CreateContentUpdatedAtIndex(ctx); err != nil {
+ return fmt.Errorf("failed to create content updated_at index: %w", err)
+ }
+
+ if err := db.postgresqlQueries.CreateVersionsLookupIndex(ctx); err != nil {
+ return fmt.Errorf("failed to create versions lookup index: %w", err)
+ }
+
+ // Create update function and trigger
+ if err := db.postgresqlQueries.CreateUpdateFunction(ctx); err != nil {
+ return fmt.Errorf("failed to create update function: %w", err)
+ }
+
+ // Create trigger manually (sqlc didn't generate this)
+ triggerQuery := `
+ DROP TRIGGER IF EXISTS update_content_updated_at ON content;
+ CREATE TRIGGER update_content_updated_at
+ BEFORE UPDATE ON content
+ FOR EACH ROW
+ EXECUTE FUNCTION update_content_timestamp();`
+
+ if _, err := db.conn.Exec(triggerQuery); err != nil {
+ return fmt.Errorf("failed to create update trigger: %w", err)
+ }
+
+ return nil
+}
diff --git a/insertr-server/internal/db/postgresql/content.sql.go b/insertr-server/internal/db/postgresql/content.sql.go
new file mode 100644
index 0000000..b3230c3
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/content.sql.go
@@ -0,0 +1,214 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: content.sql
+
+package postgresql
+
+import (
+ "context"
+ "strings"
+)
+
+const createContent = `-- name: CreateContent :one
+INSERT INTO content (id, site_id, value, type, last_edited_by)
+VALUES ($1, $2, $3, $4, $5)
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
+`
+
+type CreateContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ LastEditedBy string `json:"last_edited_by"`
+}
+
+func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, createContent,
+ arg.ID,
+ arg.SiteID,
+ arg.Value,
+ arg.Type,
+ arg.LastEditedBy,
+ )
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
+
+const deleteContent = `-- name: DeleteContent :exec
+DELETE FROM content
+WHERE id = $1 AND site_id = $2
+`
+
+type DeleteContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) error {
+ _, err := q.db.ExecContext(ctx, deleteContent, arg.ID, arg.SiteID)
+ return err
+}
+
+const getAllContent = `-- name: GetAllContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = $1
+ORDER BY updated_at DESC
+`
+
+func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content, error) {
+ rows, err := q.db.QueryContext(ctx, getAllContent, siteID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Content
+ for rows.Next() {
+ var i Content
+ if err := rows.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getBulkContent = `-- name: GetBulkContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = $1 AND id IN ($2)
+`
+
+type GetBulkContentParams struct {
+ SiteID string `json:"site_id"`
+ Ids []string `json:"ids"`
+}
+
+func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) {
+ query := getBulkContent
+ var queryParams []interface{}
+ queryParams = append(queryParams, arg.SiteID)
+ if len(arg.Ids) > 0 {
+ for _, v := range arg.Ids {
+ queryParams = append(queryParams, v)
+ }
+ query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1)
+ } else {
+ query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
+ }
+ rows, err := q.db.QueryContext(ctx, query, queryParams...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Content
+ for rows.Next() {
+ var i Content
+ if err := rows.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getContent = `-- name: GetContent :one
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE id = $1 AND site_id = $2
+`
+
+type GetContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, getContent, arg.ID, arg.SiteID)
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
+
+const updateContent = `-- name: UpdateContent :one
+UPDATE content
+SET value = $1, type = $2, last_edited_by = $3
+WHERE id = $4 AND site_id = $5
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
+`
+
+type UpdateContentParams struct {
+ Value string `json:"value"`
+ Type string `json:"type"`
+ LastEditedBy string `json:"last_edited_by"`
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, updateContent,
+ arg.Value,
+ arg.Type,
+ arg.LastEditedBy,
+ arg.ID,
+ arg.SiteID,
+ )
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
diff --git a/insertr-server/internal/db/postgresql/db.go b/insertr-server/internal/db/postgresql/db.go
new file mode 100644
index 0000000..9f77c9d
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/db.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package postgresql
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/insertr-server/internal/db/postgresql/models.go b/insertr-server/internal/db/postgresql/models.go
new file mode 100644
index 0000000..7a53776
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/models.go
@@ -0,0 +1,25 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package postgresql
+
+type Content struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ LastEditedBy string `json:"last_edited_by"`
+}
+
+type ContentVersion struct {
+ VersionID int32 `json:"version_id"`
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+}
diff --git a/insertr-server/internal/db/postgresql/querier.go b/insertr-server/internal/db/postgresql/querier.go
new file mode 100644
index 0000000..37cf939
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/querier.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package postgresql
+
+import (
+ "context"
+)
+
+type Querier interface {
+ CreateContent(ctx context.Context, arg CreateContentParams) (Content, error)
+ CreateContentSiteIndex(ctx context.Context) error
+ CreateContentUpdatedAtIndex(ctx context.Context) error
+ CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error
+ CreateUpdateFunction(ctx context.Context) error
+ CreateVersionsLookupIndex(ctx context.Context) error
+ DeleteContent(ctx context.Context, arg DeleteContentParams) error
+ DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error
+ GetAllContent(ctx context.Context, siteID string) ([]Content, error)
+ GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error)
+ GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error)
+ GetContent(ctx context.Context, arg GetContentParams) (Content, error)
+ GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error)
+ GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error)
+ InitializeSchema(ctx context.Context) error
+ InitializeVersionsTable(ctx context.Context) error
+ UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/insertr-server/internal/db/postgresql/setup.sql.go b/insertr-server/internal/db/postgresql/setup.sql.go
new file mode 100644
index 0000000..030a0e0
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/setup.sql.go
@@ -0,0 +1,87 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: setup.sql
+
+package postgresql
+
+import (
+ "context"
+)
+
+const createContentSiteIndex = `-- name: CreateContentSiteIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id)
+`
+
+func (q *Queries) CreateContentSiteIndex(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, createContentSiteIndex)
+ return err
+}
+
+const createContentUpdatedAtIndex = `-- name: CreateContentUpdatedAtIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at)
+`
+
+func (q *Queries) CreateContentUpdatedAtIndex(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, createContentUpdatedAtIndex)
+ return err
+}
+
+const createUpdateFunction = `-- name: CreateUpdateFunction :exec
+CREATE OR REPLACE FUNCTION update_content_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = EXTRACT(EPOCH FROM NOW());
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql
+`
+
+func (q *Queries) CreateUpdateFunction(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, createUpdateFunction)
+ return err
+}
+
+const createVersionsLookupIndex = `-- name: CreateVersionsLookupIndex :exec
+CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC)
+`
+
+func (q *Queries) CreateVersionsLookupIndex(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, createVersionsLookupIndex)
+ return err
+}
+
+const initializeSchema = `-- name: InitializeSchema :exec
+CREATE TABLE IF NOT EXISTS content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+)
+`
+
+func (q *Queries) InitializeSchema(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, initializeSchema)
+ return err
+}
+
+const initializeVersionsTable = `-- name: InitializeVersionsTable :exec
+CREATE TABLE IF NOT EXISTS content_versions (
+ version_id SERIAL PRIMARY KEY,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+)
+`
+
+func (q *Queries) InitializeVersionsTable(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, initializeVersionsTable)
+ return err
+}
diff --git a/insertr-server/internal/db/postgresql/versions.sql.go b/insertr-server/internal/db/postgresql/versions.sql.go
new file mode 100644
index 0000000..00bd5d3
--- /dev/null
+++ b/insertr-server/internal/db/postgresql/versions.sql.go
@@ -0,0 +1,175 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: versions.sql
+
+package postgresql
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createContentVersion = `-- name: CreateContentVersion :exec
+INSERT INTO content_versions (content_id, site_id, value, type, created_by)
+VALUES ($1, $2, $3, $4, $5)
+`
+
+type CreateContentVersionParams struct {
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedBy string `json:"created_by"`
+}
+
+func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error {
+ _, err := q.db.ExecContext(ctx, createContentVersion,
+ arg.ContentID,
+ arg.SiteID,
+ arg.Value,
+ arg.Type,
+ arg.CreatedBy,
+ )
+ return err
+}
+
+const deleteOldVersions = `-- name: DeleteOldVersions :exec
+DELETE FROM content_versions
+WHERE created_at < $1 AND site_id = $2
+`
+
+type DeleteOldVersionsParams struct {
+ CreatedBefore int64 `json:"created_before"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error {
+ _, err := q.db.ExecContext(ctx, deleteOldVersions, arg.CreatedBefore, arg.SiteID)
+ return err
+}
+
+const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many
+SELECT
+ cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
+ c.value as current_value
+FROM content_versions cv
+LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
+WHERE cv.site_id = $1
+ORDER BY cv.created_at DESC
+LIMIT $2
+`
+
+type GetAllVersionsForSiteParams struct {
+ SiteID string `json:"site_id"`
+ LimitCount int32 `json:"limit_count"`
+}
+
+type GetAllVersionsForSiteRow struct {
+ VersionID int32 `json:"version_id"`
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+ CurrentValue sql.NullString `json:"current_value"`
+}
+
+func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllVersionsForSite, arg.SiteID, arg.LimitCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllVersionsForSiteRow
+ for rows.Next() {
+ var i GetAllVersionsForSiteRow
+ if err := rows.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ &i.CurrentValue,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getContentVersion = `-- name: GetContentVersion :one
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE version_id = $1
+`
+
+func (q *Queries) GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error) {
+ row := q.db.QueryRowContext(ctx, getContentVersion, versionID)
+ var i ContentVersion
+ err := row.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ )
+ return i, err
+}
+
+const getContentVersionHistory = `-- name: GetContentVersionHistory :many
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE content_id = $1 AND site_id = $2
+ORDER BY created_at DESC
+LIMIT $3
+`
+
+type GetContentVersionHistoryParams struct {
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ LimitCount int32 `json:"limit_count"`
+}
+
+func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) {
+ rows, err := q.db.QueryContext(ctx, getContentVersionHistory, arg.ContentID, arg.SiteID, arg.LimitCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ContentVersion
+ for rows.Next() {
+ var i ContentVersion
+ if err := rows.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/insertr-server/internal/db/sqlite.go b/insertr-server/internal/db/sqlite.go
deleted file mode 100644
index 7f62edb..0000000
--- a/insertr-server/internal/db/sqlite.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package db
-
-import (
- "database/sql"
- "fmt"
- "time"
-
- "github.com/insertr/server/internal/models"
- _ "github.com/mattn/go-sqlite3"
-)
-
-// SQLiteDB wraps a SQLite database connection
-type SQLiteDB struct {
- db *sql.DB
-}
-
-// NewSQLiteDB creates a new SQLite database connection
-func NewSQLiteDB(dbPath string) (*SQLiteDB, error) {
- db, err := sql.Open("sqlite3", dbPath)
- if err != nil {
- return nil, fmt.Errorf("opening database: %w", err)
- }
-
- // Test connection
- if err := db.Ping(); err != nil {
- return nil, fmt.Errorf("connecting to database: %w", err)
- }
-
- sqliteDB := &SQLiteDB{db: db}
-
- // Initialize schema
- if err := sqliteDB.initSchema(); err != nil {
- return nil, fmt.Errorf("initializing schema: %w", err)
- }
-
- return sqliteDB, nil
-}
-
-// Close closes the database connection
-func (s *SQLiteDB) Close() error {
- return s.db.Close()
-}
-
-// initSchema creates the necessary tables
-func (s *SQLiteDB) initSchema() error {
- schema := `
- CREATE TABLE IF NOT EXISTS content (
- id TEXT NOT NULL,
- site_id TEXT NOT NULL,
- value TEXT NOT NULL,
- type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (id, site_id)
- );
-
- CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
- CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);
-
- -- Trigger to update updated_at timestamp
- CREATE TRIGGER IF NOT EXISTS update_content_updated_at
- AFTER UPDATE ON content
- FOR EACH ROW
- BEGIN
- UPDATE content SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id AND site_id = NEW.site_id;
- END;
- `
-
- if _, err := s.db.Exec(schema); err != nil {
- return fmt.Errorf("creating schema: %w", err)
- }
-
- return nil
-}
-
-// GetContent fetches a single content item by ID and site ID
-func (s *SQLiteDB) GetContent(siteID, contentID string) (*models.ContentItem, error) {
- query := `
- SELECT id, site_id, value, type, created_at, updated_at
- FROM content
- WHERE id = ? AND site_id = ?
- `
-
- var item models.ContentItem
- err := s.db.QueryRow(query, contentID, siteID).Scan(
- &item.ID, &item.SiteID, &item.Value, &item.Type, &item.CreatedAt, &item.UpdatedAt,
- )
-
- if err == sql.ErrNoRows {
- return nil, nil // Content not found
- }
- if err != nil {
- return nil, fmt.Errorf("querying content: %w", err)
- }
-
- return &item, nil
-}
-
-// GetAllContent fetches all content for a site
-func (s *SQLiteDB) GetAllContent(siteID string) ([]models.ContentItem, error) {
- query := `
- SELECT id, site_id, value, type, created_at, updated_at
- FROM content
- WHERE site_id = ?
- ORDER BY updated_at DESC
- `
-
- rows, err := s.db.Query(query, siteID)
- if err != nil {
- return nil, fmt.Errorf("querying all content: %w", err)
- }
- defer rows.Close()
-
- var items []models.ContentItem
- for rows.Next() {
- var item models.ContentItem
- err := rows.Scan(&item.ID, &item.SiteID, &item.Value, &item.Type, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, fmt.Errorf("scanning content row: %w", err)
- }
- items = append(items, item)
- }
-
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterating content rows: %w", err)
- }
-
- return items, nil
-}
-
-// GetBulkContent fetches multiple content items by IDs
-func (s *SQLiteDB) GetBulkContent(siteID string, contentIDs []string) ([]models.ContentItem, error) {
- if len(contentIDs) == 0 {
- return []models.ContentItem{}, nil
- }
-
- // Build placeholders for IN clause
- placeholders := make([]interface{}, len(contentIDs)+1)
- placeholders[0] = siteID
- for i, id := range contentIDs {
- placeholders[i+1] = id
- }
-
- // Build query with proper number of placeholders
- query := fmt.Sprintf(`
- SELECT id, site_id, value, type, created_at, updated_at
- FROM content
- WHERE site_id = ? AND id IN (%s)
- ORDER BY updated_at DESC
- `, buildPlaceholders(len(contentIDs)))
-
- rows, err := s.db.Query(query, placeholders...)
- if err != nil {
- return nil, fmt.Errorf("querying bulk content: %w", err)
- }
- defer rows.Close()
-
- var items []models.ContentItem
- for rows.Next() {
- var item models.ContentItem
- err := rows.Scan(&item.ID, &item.SiteID, &item.Value, &item.Type, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, fmt.Errorf("scanning bulk content row: %w", err)
- }
- items = append(items, item)
- }
-
- return items, nil
-}
-
-// CreateContent creates a new content item
-func (s *SQLiteDB) CreateContent(siteID, contentID, value, contentType string) (*models.ContentItem, error) {
- now := time.Now()
-
- query := `
- INSERT INTO content (id, site_id, value, type, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `
-
- _, err := s.db.Exec(query, contentID, siteID, value, contentType, now, now)
- if err != nil {
- return nil, fmt.Errorf("creating content: %w", err)
- }
-
- return &models.ContentItem{
- ID: contentID,
- SiteID: siteID,
- Value: value,
- Type: contentType,
- CreatedAt: now,
- UpdatedAt: now,
- }, nil
-}
-
-// UpdateContent updates an existing content item
-func (s *SQLiteDB) UpdateContent(siteID, contentID, value string) (*models.ContentItem, error) {
- // First check if content exists
- existing, err := s.GetContent(siteID, contentID)
- if err != nil {
- return nil, fmt.Errorf("checking existing content: %w", err)
- }
- if existing == nil {
- return nil, fmt.Errorf("content not found: %s", contentID)
- }
-
- query := `
- UPDATE content
- SET value = ?, updated_at = CURRENT_TIMESTAMP
- WHERE id = ? AND site_id = ?
- `
-
- _, err = s.db.Exec(query, value, contentID, siteID)
- if err != nil {
- return nil, fmt.Errorf("updating content: %w", err)
- }
-
- // Fetch and return updated content
- return s.GetContent(siteID, contentID)
-}
-
-// buildPlaceholders creates a string of SQL placeholders like "?,?,?"
-func buildPlaceholders(count int) string {
- if count == 0 {
- return ""
- }
-
- result := "?"
- for i := 1; i < count; i++ {
- result += ",?"
- }
- return result
-}
diff --git a/insertr-server/internal/db/sqlite/content.sql.go b/insertr-server/internal/db/sqlite/content.sql.go
new file mode 100644
index 0000000..ce90e1a
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/content.sql.go
@@ -0,0 +1,214 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: content.sql
+
+package sqlite
+
+import (
+ "context"
+ "strings"
+)
+
+const createContent = `-- name: CreateContent :one
+INSERT INTO content (id, site_id, value, type, last_edited_by)
+VALUES (?1, ?2, ?3, ?4, ?5)
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
+`
+
+type CreateContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ LastEditedBy string `json:"last_edited_by"`
+}
+
+func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, createContent,
+ arg.ID,
+ arg.SiteID,
+ arg.Value,
+ arg.Type,
+ arg.LastEditedBy,
+ )
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
+
+const deleteContent = `-- name: DeleteContent :exec
+DELETE FROM content
+WHERE id = ?1 AND site_id = ?2
+`
+
+type DeleteContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) error {
+ _, err := q.db.ExecContext(ctx, deleteContent, arg.ID, arg.SiteID)
+ return err
+}
+
+const getAllContent = `-- name: GetAllContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = ?1
+ORDER BY updated_at DESC
+`
+
+func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content, error) {
+ rows, err := q.db.QueryContext(ctx, getAllContent, siteID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Content
+ for rows.Next() {
+ var i Content
+ if err := rows.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getBulkContent = `-- name: GetBulkContent :many
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE site_id = ?1 AND id IN (/*SLICE:ids*/?)
+`
+
+type GetBulkContentParams struct {
+ SiteID string `json:"site_id"`
+ Ids []string `json:"ids"`
+}
+
+func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) {
+ query := getBulkContent
+ var queryParams []interface{}
+ queryParams = append(queryParams, arg.SiteID)
+ if len(arg.Ids) > 0 {
+ for _, v := range arg.Ids {
+ queryParams = append(queryParams, v)
+ }
+ query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1)
+ } else {
+ query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
+ }
+ rows, err := q.db.QueryContext(ctx, query, queryParams...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Content
+ for rows.Next() {
+ var i Content
+ if err := rows.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getContent = `-- name: GetContent :one
+SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
+FROM content
+WHERE id = ?1 AND site_id = ?2
+`
+
+type GetContentParams struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, getContent, arg.ID, arg.SiteID)
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
+
+const updateContent = `-- name: UpdateContent :one
+UPDATE content
+SET value = ?1, type = ?2, last_edited_by = ?3
+WHERE id = ?4 AND site_id = ?5
+RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
+`
+
+type UpdateContentParams struct {
+ Value string `json:"value"`
+ Type string `json:"type"`
+ LastEditedBy string `json:"last_edited_by"`
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) {
+ row := q.db.QueryRowContext(ctx, updateContent,
+ arg.Value,
+ arg.Type,
+ arg.LastEditedBy,
+ arg.ID,
+ arg.SiteID,
+ )
+ var i Content
+ err := row.Scan(
+ &i.ID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastEditedBy,
+ )
+ return i, err
+}
diff --git a/insertr-server/internal/db/sqlite/db.go b/insertr-server/internal/db/sqlite/db.go
new file mode 100644
index 0000000..5841324
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/db.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/insertr-server/internal/db/sqlite/models.go b/insertr-server/internal/db/sqlite/models.go
new file mode 100644
index 0000000..d8e7a1c
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/models.go
@@ -0,0 +1,25 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package sqlite
+
+type Content struct {
+ ID string `json:"id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ LastEditedBy string `json:"last_edited_by"`
+}
+
+type ContentVersion struct {
+ VersionID int64 `json:"version_id"`
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+}
diff --git a/insertr-server/internal/db/sqlite/querier.go b/insertr-server/internal/db/sqlite/querier.go
new file mode 100644
index 0000000..f2c5dac
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/querier.go
@@ -0,0 +1,27 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package sqlite
+
+import (
+ "context"
+)
+
+type Querier interface {
+ CreateContent(ctx context.Context, arg CreateContentParams) (Content, error)
+ CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error
+ DeleteContent(ctx context.Context, arg DeleteContentParams) error
+ DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error
+ GetAllContent(ctx context.Context, siteID string) ([]Content, error)
+ GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error)
+ GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error)
+ GetContent(ctx context.Context, arg GetContentParams) (Content, error)
+ GetContentVersion(ctx context.Context, versionID int64) (ContentVersion, error)
+ GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error)
+ InitializeSchema(ctx context.Context) error
+ InitializeVersionsTable(ctx context.Context) error
+ UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/insertr-server/internal/db/sqlite/setup.sql.go b/insertr-server/internal/db/sqlite/setup.sql.go
new file mode 100644
index 0000000..800ef7e
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/setup.sql.go
@@ -0,0 +1,45 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: setup.sql
+
+package sqlite
+
+import (
+ "context"
+)
+
+const initializeSchema = `-- name: InitializeSchema :exec
+CREATE TABLE IF NOT EXISTS content (
+ id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ last_edited_by TEXT DEFAULT 'system' NOT NULL,
+ PRIMARY KEY (id, site_id)
+)
+`
+
+func (q *Queries) InitializeSchema(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, initializeSchema)
+ return err
+}
+
+const initializeVersionsTable = `-- name: InitializeVersionsTable :exec
+CREATE TABLE IF NOT EXISTS content_versions (
+ version_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content_id TEXT NOT NULL,
+ site_id TEXT NOT NULL,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL,
+ created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
+ created_by TEXT DEFAULT 'system' NOT NULL
+)
+`
+
+func (q *Queries) InitializeVersionsTable(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, initializeVersionsTable)
+ return err
+}
diff --git a/insertr-server/internal/db/sqlite/versions.sql.go b/insertr-server/internal/db/sqlite/versions.sql.go
new file mode 100644
index 0000000..8d46807
--- /dev/null
+++ b/insertr-server/internal/db/sqlite/versions.sql.go
@@ -0,0 +1,175 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: versions.sql
+
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createContentVersion = `-- name: CreateContentVersion :exec
+INSERT INTO content_versions (content_id, site_id, value, type, created_by)
+VALUES (?1, ?2, ?3, ?4, ?5)
+`
+
+type CreateContentVersionParams struct {
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedBy string `json:"created_by"`
+}
+
+func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error {
+ _, err := q.db.ExecContext(ctx, createContentVersion,
+ arg.ContentID,
+ arg.SiteID,
+ arg.Value,
+ arg.Type,
+ arg.CreatedBy,
+ )
+ return err
+}
+
+const deleteOldVersions = `-- name: DeleteOldVersions :exec
+DELETE FROM content_versions
+WHERE created_at < ?1 AND site_id = ?2
+`
+
+type DeleteOldVersionsParams struct {
+ CreatedBefore int64 `json:"created_before"`
+ SiteID string `json:"site_id"`
+}
+
+func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error {
+ _, err := q.db.ExecContext(ctx, deleteOldVersions, arg.CreatedBefore, arg.SiteID)
+ return err
+}
+
+const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many
+SELECT
+ cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
+ c.value as current_value
+FROM content_versions cv
+LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
+WHERE cv.site_id = ?1
+ORDER BY cv.created_at DESC
+LIMIT ?2
+`
+
+type GetAllVersionsForSiteParams struct {
+ SiteID string `json:"site_id"`
+ LimitCount int64 `json:"limit_count"`
+}
+
+type GetAllVersionsForSiteRow struct {
+ VersionID int64 `json:"version_id"`
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ CreatedAt int64 `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+ CurrentValue sql.NullString `json:"current_value"`
+}
+
+func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllVersionsForSite, arg.SiteID, arg.LimitCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllVersionsForSiteRow
+ for rows.Next() {
+ var i GetAllVersionsForSiteRow
+ if err := rows.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ &i.CurrentValue,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getContentVersion = `-- name: GetContentVersion :one
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE version_id = ?1
+`
+
+func (q *Queries) GetContentVersion(ctx context.Context, versionID int64) (ContentVersion, error) {
+ row := q.db.QueryRowContext(ctx, getContentVersion, versionID)
+ var i ContentVersion
+ err := row.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ )
+ return i, err
+}
+
+const getContentVersionHistory = `-- name: GetContentVersionHistory :many
+SELECT version_id, content_id, site_id, value, type, created_at, created_by
+FROM content_versions
+WHERE content_id = ?1 AND site_id = ?2
+ORDER BY created_at DESC
+LIMIT ?3
+`
+
+type GetContentVersionHistoryParams struct {
+ ContentID string `json:"content_id"`
+ SiteID string `json:"site_id"`
+ LimitCount int64 `json:"limit_count"`
+}
+
+func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) {
+ rows, err := q.db.QueryContext(ctx, getContentVersionHistory, arg.ContentID, arg.SiteID, arg.LimitCount)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ContentVersion
+ for rows.Next() {
+ var i ContentVersion
+ if err := rows.Scan(
+ &i.VersionID,
+ &i.ContentID,
+ &i.SiteID,
+ &i.Value,
+ &i.Type,
+ &i.CreatedAt,
+ &i.CreatedBy,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/insertr-server/internal/models/content.go b/insertr-server/internal/models/content.go
deleted file mode 100644
index d025394..0000000
--- a/insertr-server/internal/models/content.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package models
-
-import (
- "time"
-)
-
-// ContentItem represents a piece of content in the database
-// This matches the structure used by the CLI client and JavaScript client
-type ContentItem struct {
- ID string `json:"id" db:"id"`
- SiteID string `json:"site_id" db:"site_id"`
- Value string `json:"value" db:"value"`
- Type string `json:"type" db:"type"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-
-// ContentResponse represents the API response structure for multiple items
-type ContentResponse struct {
- Content []ContentItem `json:"content"`
- Error string `json:"error,omitempty"`
-}
-
-// CreateContentRequest represents the request structure for creating content
-type CreateContentRequest struct {
- ID string `json:"id" validate:"required"`
- Value string `json:"value" validate:"required"`
- Type string `json:"type" validate:"required,oneof=text markdown link"`
-}
-
-// UpdateContentRequest represents the request structure for updating content
-type UpdateContentRequest struct {
- Value string `json:"value" validate:"required"`
-}
diff --git a/insertr-server/sqlc.yaml b/insertr-server/sqlc.yaml
new file mode 100644
index 0000000..4fea7a4
--- /dev/null
+++ b/insertr-server/sqlc.yaml
@@ -0,0 +1,31 @@
+version: "2"
+sql:
+ # SQLite configuration for development
+ - name: "sqlite"
+ engine: "sqlite"
+ queries: ["db/queries/", "db/sqlite/setup.sql"]
+ schema: "db/sqlite/schema.sql"
+ gen:
+ go:
+ package: "sqlite"
+ out: "internal/db/sqlite"
+ emit_json_tags: true
+ emit_prepared_queries: false
+ emit_interface: true
+ emit_exact_table_names: false
+ emit_pointers_for_null_types: false # All fields are NOT NULL now
+
+ # PostgreSQL configuration for production
+ - name: "postgresql"
+ engine: "postgresql"
+ queries: ["db/queries/", "db/postgresql/setup.sql"]
+ schema: "db/postgresql/schema.sql"
+ gen:
+ go:
+ package: "postgresql"
+ out: "internal/db/postgresql"
+ emit_json_tags: true
+ emit_prepared_queries: false
+ emit_interface: true
+ emit_exact_table_names: false
+ emit_pointers_for_null_types: false # All fields are NOT NULL now
\ No newline at end of file
diff --git a/justfile b/justfile
index e69db4e..4c77e9d 100644
--- a/justfile
+++ b/justfile
@@ -119,6 +119,10 @@ servedev:
# === Content API Server Commands ===
+# Generate Go code from SQL (using sqlc)
+server-generate:
+ cd insertr-server && sqlc generate
+
# Build the content API server binary
server-build:
cd insertr-server && go build -o insertr-server ./cmd/server
@@ -136,6 +140,12 @@ server-health port="8080":
@echo "🔍 Checking API server health..."
@curl -s http://localhost:{{port}}/health | jq . || echo "❌ Server not responding at localhost:{{port}}"
+# Clean database (development only - removes all content!)
+server-clean-db:
+ @echo "🗑️ Removing development database..."
+ rm -f insertr-server/insertr.db
+ @echo "✅ Database cleaned (will be recreated on next server start)"
+
# Clean all build artifacts
clean:
rm -rf lib/dist
diff --git a/lib/src/core/api-client.js b/lib/src/core/api-client.js
index 8c78f68..4bdc2a4 100644
--- a/lib/src/core/api-client.js
+++ b/lib/src/core/api-client.js
@@ -33,7 +33,8 @@ export class ApiClient {
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`, {
method: 'PUT',
headers: {
- 'Content-Type': 'application/json'
+ 'Content-Type': 'application/json',
+ 'X-User-ID': this.getCurrentUser()
},
body: JSON.stringify({ value: content })
});
@@ -62,7 +63,8 @@ export class ApiClient {
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
method: 'POST',
headers: {
- 'Content-Type': 'application/json'
+ 'Content-Type': 'application/json',
+ 'X-User-ID': this.getCurrentUser()
},
body: JSON.stringify({
id: contentId,
@@ -88,4 +90,52 @@ export class ApiClient {
return false;
}
}
+
+ async getContentVersions(contentId) {
+ try {
+ const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);
+ if (response.ok) {
+ const result = await response.json();
+ return result.versions || [];
+ } else {
+ console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`);
+ return [];
+ }
+ } catch (error) {
+ console.error('Failed to fetch version history:', contentId, error);
+ return [];
+ }
+ }
+
+ async rollbackContent(contentId, versionId) {
+ try {
+ const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-User-ID': this.getCurrentUser()
+ },
+ body: JSON.stringify({
+ version_id: versionId
+ })
+ });
+
+ if (response.ok) {
+ console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`);
+ return await response.json();
+ } else {
+ console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`);
+ return false;
+ }
+ } catch (error) {
+ console.error('Failed to rollback content:', contentId, error);
+ return false;
+ }
+ }
+
+ // Helper to get current user (for user attribution)
+ getCurrentUser() {
+ // This could be enhanced to get from authentication system
+ return 'anonymous';
+ }
}
\ No newline at end of file
diff --git a/lib/src/core/editor.js b/lib/src/core/editor.js
index d82fc0b..40fafaf 100644
--- a/lib/src/core/editor.js
+++ b/lib/src/core/editor.js
@@ -10,7 +10,7 @@ export class InsertrEditor {
this.apiClient = apiClient;
this.options = options;
this.isActive = false;
- this.formRenderer = new InsertrFormRenderer();
+ this.formRenderer = new InsertrFormRenderer(apiClient);
}
start() {
@@ -200,6 +200,178 @@ export class InsertrEditor {
z-index: 1000;
font-family: monospace;
}
+
+ /* Version History Modal Styles */
+ .insertr-version-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10001;
+ }
+
+ .insertr-version-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ }
+
+ .insertr-version-content-modal {
+ background: white;
+ border-radius: 8px;
+ max-width: 600px;
+ width: 100%;
+ max-height: 80vh;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ }
+
+ .insertr-version-header {
+ padding: 20px 20px 0;
+ border-bottom: 1px solid #eee;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
+ }
+
+ .insertr-version-header h3 {
+ margin: 0 0 20px;
+ color: #333;
+ font-size: 18px;
+ }
+
+ .insertr-btn-close {
+ background: none;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ color: #666;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .insertr-btn-close:hover {
+ color: #333;
+ }
+
+ .insertr-version-list {
+ overflow-y: auto;
+ padding: 20px;
+ flex: 1;
+ }
+
+ .insertr-version-item {
+ border: 1px solid #e1e5e9;
+ border-radius: 6px;
+ padding: 16px;
+ margin-bottom: 12px;
+ background: #f8f9fa;
+ }
+
+ .insertr-version-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+ font-size: 13px;
+ }
+
+ .insertr-version-label {
+ font-weight: 600;
+ color: #0969da;
+ }
+
+ .insertr-version-date {
+ color: #656d76;
+ }
+
+ .insertr-version-user {
+ color: #656d76;
+ }
+
+ .insertr-version-content {
+ margin-bottom: 12px;
+ padding: 8px;
+ background: white;
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 14px;
+ color: #24292f;
+ white-space: pre-wrap;
+ }
+
+ .insertr-version-actions {
+ display: flex;
+ gap: 8px;
+ }
+
+ .insertr-btn-restore {
+ background: #0969da;
+ color: white;
+ border: none;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ .insertr-btn-restore:hover {
+ background: #0860ca;
+ }
+
+ .insertr-btn-view-diff {
+ background: #f6f8fa;
+ color: #24292f;
+ border: 1px solid #d1d9e0;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ .insertr-btn-view-diff:hover {
+ background: #f3f4f6;
+ }
+
+ .insertr-version-empty {
+ text-align: center;
+ color: #656d76;
+ font-style: italic;
+ padding: 40px 20px;
+ }
+
+ /* History Button in Form */
+ .insertr-btn-history {
+ background: #6f42c1;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ margin-left: auto;
+ }
+
+ .insertr-btn-history:hover {
+ background: #5a359a;
+ }
`;
const styleSheet = document.createElement('style');
diff --git a/lib/src/core/insertr.js b/lib/src/core/insertr.js
index d5c1e02..ed6e80b 100644
--- a/lib/src/core/insertr.js
+++ b/lib/src/core/insertr.js
@@ -105,17 +105,205 @@ export class InsertrCore {
// Get element metadata
getElementMetadata(element) {
return {
- contentId: element.getAttribute('data-content-id') || this.generateTempId(element),
+ contentId: element.getAttribute('data-content-id') || this.generateDeterministicId(element),
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
element: element
};
}
- // Generate temporary ID for elements without data-content-id
+ // Generate deterministic ID using same algorithm as CLI parser
generateTempId(element) {
+ return this.generateDeterministicId(element);
+ }
+
+ // Generate deterministic content ID (matches CLI parser algorithm)
+ generateDeterministicId(element) {
+ const context = this.getSemanticContext(element);
+ const purpose = this.getPurpose(element);
+ const contentHash = this.getContentHash(element);
+
+ return this.createBaseId(context, purpose, contentHash);
+ }
+
+ // Get semantic context from parent elements (matches CLI algorithm)
+ getSemanticContext(element) {
+ let parent = element.parentElement;
+
+ while (parent && parent.nodeType === Node.ELEMENT_NODE) {
+ const classList = Array.from(parent.classList);
+
+ // Check for common semantic section classes
+ const semanticClasses = ['hero', 'services', 'nav', 'navbar', 'footer', 'about', 'contact', 'testimonial'];
+ for (const semanticClass of semanticClasses) {
+ if (classList.includes(semanticClass)) {
+ return semanticClass;
+ }
+ }
+
+ // Check for semantic HTML elements
+ const tag = parent.tagName.toLowerCase();
+ if (['nav', 'header', 'footer', 'main', 'aside'].includes(tag)) {
+ return tag;
+ }
+
+ parent = parent.parentElement;
+ }
+
+ return 'content';
+ }
+
+ // Get purpose/role of the element (matches CLI algorithm)
+ getPurpose(element) {
const tag = element.tagName.toLowerCase();
- const text = element.textContent.trim().substring(0, 20).replace(/\s+/g, '-').toLowerCase();
- return `${tag}-${text}-${Date.now()}`;
+ const classList = Array.from(element.classList);
+
+ // Check for specific CSS classes that indicate purpose
+ for (const className of classList) {
+ if (className.includes('title')) return 'title';
+ if (className.includes('headline')) return 'headline';
+ if (className.includes('description')) return 'description';
+ if (className.includes('subtitle')) return 'subtitle';
+ if (className.includes('cta')) return 'cta';
+ if (className.includes('button')) return 'button';
+ if (className.includes('logo')) return 'logo';
+ if (className.includes('lead')) return 'lead';
+ }
+
+ // Infer purpose from HTML tag
+ switch (tag) {
+ case 'h1':
+ return 'title';
+ case 'h2':
+ return 'subtitle';
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ return 'heading';
+ case 'p':
+ return 'text';
+ case 'a':
+ return 'link';
+ case 'button':
+ return 'button';
+ default:
+ return 'content';
+ }
+ }
+
+ // Generate content hash (matches CLI algorithm)
+ getContentHash(element) {
+ const text = element.textContent.trim();
+
+ // Simple SHA-1 implementation for consistent hashing
+ return this.sha1(text).substring(0, 6);
+ }
+
+ // Simple SHA-1 implementation (matches Go crypto/sha1)
+ sha1(str) {
+ // Convert string to UTF-8 bytes
+ const utf8Bytes = new TextEncoder().encode(str);
+
+ // SHA-1 implementation
+ const h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
+ const messageLength = utf8Bytes.length;
+
+ // Pre-processing: adding padding bits
+ const paddedMessage = new Uint8Array(Math.ceil((messageLength + 9) / 64) * 64);
+ paddedMessage.set(utf8Bytes);
+ paddedMessage[messageLength] = 0x80;
+
+ // Append original length in bits as 64-bit big-endian integer
+ const bitLength = messageLength * 8;
+ const view = new DataView(paddedMessage.buffer);
+ view.setUint32(paddedMessage.length - 4, bitLength, false); // big-endian
+
+ // Process message in 512-bit chunks
+ for (let chunk = 0; chunk < paddedMessage.length; chunk += 64) {
+ const w = new Array(80);
+
+ // Break chunk into sixteen 32-bit words
+ for (let i = 0; i < 16; i++) {
+ w[i] = view.getUint32(chunk + i * 4, false); // big-endian
+ }
+
+ // Extend the words
+ for (let i = 16; i < 80; i++) {
+ w[i] = this.leftRotate(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1);
+ }
+
+ // Initialize hash value for this chunk
+ let [a, b, c, d, e] = h;
+
+ // Main loop
+ for (let i = 0; i < 80; i++) {
+ let f, k;
+ if (i < 20) {
+ f = (b & c) | ((~b) & d);
+ k = 0x5A827999;
+ } else if (i < 40) {
+ f = b ^ c ^ d;
+ k = 0x6ED9EBA1;
+ } else if (i < 60) {
+ f = (b & c) | (b & d) | (c & d);
+ k = 0x8F1BBCDC;
+ } else {
+ f = b ^ c ^ d;
+ k = 0xCA62C1D6;
+ }
+
+ const temp = (this.leftRotate(a, 5) + f + e + k + w[i]) >>> 0;
+ e = d;
+ d = c;
+ c = this.leftRotate(b, 30);
+ b = a;
+ a = temp;
+ }
+
+ // Add this chunk's hash to result
+ h[0] = (h[0] + a) >>> 0;
+ h[1] = (h[1] + b) >>> 0;
+ h[2] = (h[2] + c) >>> 0;
+ h[3] = (h[3] + d) >>> 0;
+ h[4] = (h[4] + e) >>> 0;
+ }
+
+ // Produce the final hash value as a 160-bit hex string
+ return h.map(x => x.toString(16).padStart(8, '0')).join('');
+ }
+
+ // Left rotate function for SHA-1
+ leftRotate(value, amount) {
+ return ((value << amount) | (value >>> (32 - amount))) >>> 0;
+ }
+
+ // Create base ID from components (matches CLI algorithm)
+ createBaseId(context, purpose, contentHash) {
+ const parts = [];
+
+ // Add context if meaningful
+ if (context !== 'content') {
+ parts.push(context);
+ }
+
+ // Add purpose
+ parts.push(purpose);
+
+ // Always add content hash for uniqueness
+ parts.push(contentHash);
+
+ let baseId = parts.join('-');
+
+ // Clean up the ID
+ baseId = baseId.replace(/-+/g, '-');
+ baseId = baseId.replace(/^-+|-+$/g, '');
+
+ // Ensure it's not empty
+ if (!baseId) {
+ baseId = `content-${contentHash}`;
+ }
+
+ return baseId;
}
// Detect content type for elements without data-content-type
diff --git a/lib/src/ui/form-renderer.js b/lib/src/ui/form-renderer.js
index f5b4dd1..4eca4e5 100644
--- a/lib/src/ui/form-renderer.js
+++ b/lib/src/ui/form-renderer.js
@@ -186,7 +186,8 @@ class LivePreviewManager {
* Enhanced with debounced live preview and comfortable input sizing
*/
export class InsertrFormRenderer {
- constructor() {
+ constructor(apiClient = null) {
+ this.apiClient = apiClient;
this.currentOverlay = null;
this.previewManager = new LivePreviewManager();
this.markdownEditor = new MarkdownEditor();
@@ -358,6 +359,7 @@ export class InsertrFormRenderer {
Save
Cancel
+ View History
`;
@@ -446,50 +448,182 @@ export class InsertrFormRenderer {
}
/**
- * Position form relative to element and ensure visibility with scroll-to-fit
+ * Get element ID for preview tracking
*/
- positionForm(element, overlay) {
- const rect = element.getBoundingClientRect();
- const form = overlay.querySelector('.insertr-edit-form');
+ getElementId(element) {
+ return element.id || element.getAttribute('data-content-id') ||
+ `element-${element.tagName}-${Date.now()}`;
+ }
- // Calculate optimal width for comfortable editing (60-80 characters)
- const viewportWidth = window.innerWidth;
- let formWidth;
+ /**
+ * Show version history modal
+ */
+ async showVersionHistory(contentId, element, onRestore) {
+ try {
+ // Get version history from API (we'll need to pass this in)
+ const apiClient = this.getApiClient();
+ const versions = await apiClient.getContentVersions(contentId);
- if (viewportWidth < 768) {
- // Mobile: prioritize usability over character count
- formWidth = Math.min(viewportWidth - 40, 500);
+ // Create version history modal
+ const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
+ document.body.appendChild(historyModal);
+
+ // Focus and setup handlers
+ this.setupVersionHistoryHandlers(historyModal, contentId);
+
+ } catch (error) {
+ console.error('Failed to load version history:', error);
+ this.showVersionHistoryError('Failed to load version history. Please try again.');
+ }
+ }
+
+ /**
+ * Create version history modal
+ */
+ createVersionHistoryModal(contentId, versions, onRestore) {
+ const modal = document.createElement('div');
+ modal.className = 'insertr-version-modal';
+
+ let versionsHTML = '';
+ if (versions && versions.length > 0) {
+ versionsHTML = versions.map((version, index) => `
+
+
+ ${index === 0 ? 'Previous Version' : `Version ${versions.length - index}`}
+ ${this.formatDate(version.created_at)}
+ ${version.created_by ? `by ${version.created_by} ` : ''}
+
+
${this.escapeHtml(this.truncateContent(version.value, 100))}
+
+ Restore
+ View Full
+
+
+ `).join('');
} else {
- // Desktop: ensure comfortable 60-80 character editing
- const minComfortableWidth = 600; // ~70 characters at 1rem
- const maxWidth = Math.min(viewportWidth * 0.9, 800); // Max 800px or 90% viewport
- const elementWidth = rect.width;
-
- // Use larger of: comfortable width, 1.5x element width, but cap at maxWidth
- formWidth = Math.max(
- minComfortableWidth,
- Math.min(elementWidth * 1.5, maxWidth)
- );
+ versionsHTML = 'No previous versions found
';
}
- form.style.width = `${formWidth}px`;
+ modal.innerHTML = `
+
+
+
+
+ ${versionsHTML}
+
+
+
+ `;
- // Position below element with some spacing
- const top = rect.bottom + window.scrollY + 10;
+ return modal;
+ }
- // Center form relative to element, but keep within viewport
- const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
- const minLeft = 20;
- const maxLeft = window.innerWidth - formWidth - 20;
- const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
+ /**
+ * Setup version history modal handlers
+ */
+ setupVersionHistoryHandlers(modal, contentId) {
+ const closeBtn = modal.querySelector('.insertr-btn-close');
+ const backdrop = modal.querySelector('.insertr-version-backdrop');
+
+ // Close handlers
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => modal.remove());
+ }
+
+ backdrop.addEventListener('click', (e) => {
+ if (e.target === backdrop) {
+ modal.remove();
+ }
+ });
- overlay.style.position = 'absolute';
- overlay.style.top = `${top}px`;
- overlay.style.left = `${left}px`;
- overlay.style.zIndex = '10000';
+ // Restore handlers
+ const restoreButtons = modal.querySelectorAll('.insertr-btn-restore');
+ restoreButtons.forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const versionId = btn.getAttribute('data-version-id');
+ if (await this.confirmRestore()) {
+ await this.restoreVersion(contentId, versionId);
+ modal.remove();
+ // Refresh the current form or close it
+ this.closeForm();
+ }
+ });
+ });
- // Ensure modal is fully visible after positioning
- this.ensureModalVisible(element, overlay);
+ // View diff handlers
+ const viewButtons = modal.querySelectorAll('.insertr-btn-view-diff');
+ viewButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const versionId = btn.getAttribute('data-version-id');
+ this.showVersionDetails(versionId);
+ });
+ });
+ }
+
+ /**
+ * Helper methods for version history
+ */
+ formatDate(dateString) {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diff = now - date;
+
+ // Less than 24 hours ago
+ if (diff < 24 * 60 * 60 * 1000) {
+ const hours = Math.floor(diff / (60 * 60 * 1000));
+ if (hours < 1) {
+ const minutes = Math.floor(diff / (60 * 1000));
+ return `${minutes}m ago`;
+ }
+ return `${hours}h ago`;
+ }
+
+ // Less than 7 days ago
+ if (diff < 7 * 24 * 60 * 60 * 1000) {
+ const days = Math.floor(diff / (24 * 60 * 60 * 1000));
+ return `${days}d ago`;
+ }
+
+ // Older - show actual date
+ return date.toLocaleDateString();
+ }
+
+ truncateContent(content, maxLength) {
+ if (content.length <= maxLength) return content;
+ return content.substring(0, maxLength) + '...';
+ }
+
+ async confirmRestore() {
+ return confirm('Are you sure you want to restore this version? This will replace the current content.');
+ }
+
+ async restoreVersion(contentId, versionId) {
+ try {
+ const apiClient = this.getApiClient();
+ await apiClient.rollbackContent(contentId, versionId);
+ return true;
+ } catch (error) {
+ console.error('Failed to restore version:', error);
+ alert('Failed to restore version. Please try again.');
+ return false;
+ }
+ }
+
+ showVersionDetails(versionId) {
+ // TODO: Implement detailed version view with diff
+ alert(`Version details not implemented yet (Version ID: ${versionId})`);
+ }
+
+ showVersionHistoryError(message) {
+ alert(message);
+ }
+
+ // Helper to get API client
+ getApiClient() {
+ return this.apiClient || window.insertrAPIClient || null;
}
/**
@@ -568,6 +702,15 @@ export class InsertrFormRenderer {
});
}
+ // Version History button
+ const historyBtn = form.querySelector('.insertr-btn-history');
+ if (historyBtn) {
+ historyBtn.addEventListener('click', () => {
+ const contentId = historyBtn.getAttribute('data-content-id');
+ this.showVersionHistory(contentId, element, onSave);
+ });
+ }
+
// ESC key to cancel
const keyHandler = (e) => {
if (e.key === 'Escape') {
diff --git a/package.json b/package.json
index 5a712e4..55bfe3d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "insertr",
"version": "0.1.0",
- "description": "The Tailwind of CMS - Zero-configuration content editing for any static site",
+ "description": "The Tailwind of CMS - Zero-configuration content editing with version control for any static site",
"main": "lib/dist/insertr.js",
"type": "module",
"scripts": {
@@ -21,10 +21,14 @@
"headless-cms",
"static-site-generator",
"content-management",
+ "version-control",
+ "content-versioning",
"build-time-enhancement",
"zero-config",
"go",
- "javascript"
+ "javascript",
+ "sqlc",
+ "sqlite"
],
"author": "Insertr Team",
"license": "MIT",
diff --git a/test-ids.html b/test-ids.html
new file mode 100644
index 0000000..92df999
--- /dev/null
+++ b/test-ids.html
@@ -0,0 +1,33 @@
+
+
+
+ Test ID Generation
+
+
+
+
+ Transform Your Business with Expert Consulting
+ We help small businesses grow through strategic planning, process optimization, and digital transformation. Our team brings 15+ years of experience to drive your success.
+ Get Started Today
+
+
+
+
+
\ No newline at end of file